|
| 1 | +# Polyglot docs composition (SP-1) — design |
| 2 | + |
| 3 | +**Date:** 2026-06-06 |
| 4 | +**Status:** Design (pending review) |
| 5 | +**Relates to:** ADR-0025 (unified docs door — this implements its multi-language direction). This is **SP-1** of a two-part program; **SP-2** (the Java api surface + a real TS+Java end-to-end conformance) follows in its own spec. |
| 6 | + |
| 7 | +## Goal |
| 8 | + |
| 9 | +Let a **polyglot solution** (e.g. TypeScript + Java over the same meta models) produce ONE coherent doc tree: the **model docs generated once** (shared, language-independent) + **one api surface per language** (idiomatic SDK reference), all **cross-linked**. SP-1 builds the TS-side composition + the contract; it is TS-only today but lays the exact shape the Java api surface (SP-2) plugs into. No model-doc duplication across languages. |
| 10 | + |
| 11 | +## Research basis (why this shape) |
| 12 | + |
| 13 | +Tools that do precisely "one neutral model → many idiomatic language outputs" converge on **an explicit list of per-language targets, each with its own output dir, in one build config**: |
| 14 | +- **buf** `buf.gen.yaml` lists `plugins[]`, each with its own `out:` per language; "managed mode" keeps the schema neutral while per-consumer options live in config. |
| 15 | +- **Smithy** `smithy-build.json` declares multiple projections + plugins (`<language>-<type>-codegen`) from one model. |
| 16 | +- **Backstage TechDocs** aggregates many components via a manifest (`catalog-info.yaml`) — but that is a *portal/federation layer ABOVE* per-project generation. |
| 17 | + |
| 18 | +**Decision:** an **explicit per-language `apiSurfaces[]` list in the `docs:` config** (the buf/Smithy pattern, and consistent with our own `generators[]`/`targets{}`). The Backstage-style **manifest is deferred** (YAGNI); we keep the internal resolver consuming a *source-agnostic resolved surface list* so a multi-repo manifest can feed the same contract later without reworking the generators. |
| 19 | + |
| 20 | +## Scope |
| 21 | + |
| 22 | +- **Build now (TS):** the `apiSurfaces[]` config, the model-page→**set-of-api-surfaces** links, per-language api subdirs, relative-or-`baseUrl` cross-link bases, the emit/skip behavior of `meta docs`, and a conformance gate proving a one-tree, **N-api-surface** layout (using a simulated second surface). |
| 23 | +- **Contract (designed, not built here):** the surface declaration + cross-link path contract that SP-2's Java api surface will emit into. |
| 24 | +- **Out of scope:** the Java api surface itself (SP-2); the federation manifest path (deferred); the non-TS model-engine reach (still deferred per ADR-0025 — *not needed* for a polyglot solution, since the model docs come from the TS toolchain once). |
| 25 | +- **Fixture hygiene (in scope):** SP-1 fixtures live **TS-package-local**, NOT in the shared `fixtures/conformance/` corpus; and SP-1 **relocates the existing `template-source-conformance` + `template-source-conformance-package` fixtures out of the shared corpus** (see "Fixture placement" below). |
| 26 | + |
| 27 | +## Design |
| 28 | + |
| 29 | +### 1. Config: `apiSurfaces[]` is the canonical api-target list |
| 30 | + |
| 31 | +> **No backwards compatibility required** — the docs door is unreleased / unused, so SP-1 replaces the single-api notion outright rather than carrying a dual path. Goldens update once to the new shape; there is no "byte-identical default" constraint. |
| 32 | +
|
| 33 | +Extend `DocsConfig` (`packages/codegen-ts/src/metaobjects-config.ts`): |
| 34 | +```ts |
| 35 | +export interface ApiSurface { |
| 36 | + lang: string; // "ts" | "java" | … (free string; label derived) |
| 37 | + subDir: string; // e.g. "api/ts"; per-language output sub-root under outDir |
| 38 | + baseUrl?: string; // when set, cross-links to THIS surface are absolute (federated) |
| 39 | +} |
| 40 | +export interface DocsConfig { |
| 41 | + outDir?: string; layout?: OutputLayout; baseUrl?: string; |
| 42 | + surfaces?: DocsSurface[]; // model/api on-off selector (drives --model/--api) |
| 43 | + apiSurfaces?: ApiSurface[]; // the api targets; default [{ lang:"ts", subDir:"api" }] |
| 44 | +} |
| 45 | +``` |
| 46 | +- `apiSurfaces` is **the** api-target list. The model page links to every entry; each language's `docs` command emits the surface whose `lang` it owns into that entry's `subDir`. The TS api surface is just `{ lang:"ts", subDir:"api" }` in that list (no special single-surface path). |
| 47 | +- `resolveDocsConfig` resolves `apiSurfaces` (default the single TS entry) into one `ResolvedDocsConfig.apiSurfaces` list — the source-agnostic seam a future manifest can also feed. |
| 48 | +- `surfaces` stays only as the model/api on-off selector (CLI `--model`/`--api`); whether the api side emits = `"api" ∈ surfaces && apiSurfaces` non-empty. |
| 49 | + |
| 50 | +### 2. Model page links to the SET of api surfaces |
| 51 | + |
| 52 | +The model entity page's single `apiPageHref` is **replaced** by `apiRefs: { label: string; href: string }[]` (one rendering path, no special-case): |
| 53 | +- rendered as `**API reference:** [TypeScript](api/ts/Order.md) · [Java](api/java/Order.md)`; a single-entry list renders one labeled link. |
| 54 | +- `label` from a small `lang → label` map (`ts`→`TypeScript`, `java`→`Java`, `kotlin`→`Kotlin`, `csharp`→`C#`, `python`→`Python`; unknown → the `lang` verbatim, capitalized). |
| 55 | + |
| 56 | +### 3. Cross-link base: relative (one tree) or `baseUrl` (federated) |
| 57 | + |
| 58 | +A helper computes each cross-link from the emitting page to a target surface page: |
| 59 | +- if the **target surface has no `baseUrl`** → relative path via the shared `surfaceCrossHref(fromOutputPath, `${subDir}/${page}`)` (Option A, one tree). |
| 60 | +- if the **target surface has a `baseUrl`** → `${baseUrl}/${page}` (Option B, federated/multi-repo). |
| 61 | +- the api→model link mirrors this using the model surface's own base (relative by default, or `docs.baseUrl` if the model docs are published separately). |
| 62 | + |
| 63 | +This makes A and B the same code path, differing only by whether a `baseUrl` is set — exactly the "build A, get B by config" decision. |
| 64 | + |
| 65 | +### 4. Who emits what (and the honest note) |
| 66 | + |
| 67 | +`meta docs` (TS): |
| 68 | +- emits the **model** surface once (when `model` ∈ surfaces), |
| 69 | +- emits **only the api surface whose `lang` this port generates** (`ts`) into its `subDir`, |
| 70 | +- renders model→links for **all** declared `apiSurfaces` (so the model page is solution-complete), |
| 71 | +- if a declared surface (e.g. `java`) is NOT emitted by this command, prints a one-line note: `meta docs: declared api surface 'java' (api/java) is produced by that port's docs command — run it to populate those pages.` |
| 72 | + |
| 73 | +The other languages' api pages are produced by their own port commands into the same tree (SP-2). In a federated setup they're produced in their own repos and linked by `baseUrl`. |
| 74 | + |
| 75 | +### 5. Conformance gate (TS-local fixtures) |
| 76 | + |
| 77 | +Because one command cannot emit another language's pages, the gate emits **all declared surfaces together** to prove integrity: |
| 78 | +- a fixture declares ≥2 `apiSurfaces` (e.g. `ts` + a **simulated second** surface standing in for `java` — produced by running the api engine again under a second `subDir`/label); |
| 79 | +- emit model + both api surfaces; assert every model→api link (one per surface) and every api→model link resolves to a real emitted page, in **flat AND package** layouts (extends the existing `docs-cross-link-conformance` pattern); |
| 80 | +- include a `baseUrl` case asserting a surface with `baseUrl` produces absolute links (and is therefore NOT expected in the local tree); |
| 81 | +- a teeth unit proving the checker flags a missing target. |
| 82 | + |
| 83 | +### 6. No back-compat constraint |
| 84 | + |
| 85 | +The docs door is unreleased, so SP-1 need not preserve current output. The model/api page goldens update **once** to the new `apiRefs` shape (the default single TS surface now renders `**API reference:** [TypeScript](api/Order.md)`). One rendering path; no dual single-vs-list special case. |
| 86 | + |
| 87 | +## Fixture placement (fixes a cross-port landmine) |
| 88 | + |
| 89 | +The shared `fixtures/conformance/` corpus is globbed by **cross-port** harnesses (C# conformance tests, the metadata-conformance bin that writes `expected.json` per fixture, etc.) that expect each case to carry cross-port expectations. The docs gates' fixtures (`template-source-conformance`, `template-source-conformance-package`) are **TS-implementation-test inputs** (input-only, no cross-port expectations) and currently sit in that shared corpus — tripping those harnesses ("no expectation files"). SP-1: |
| 90 | +- places its OWN fixtures **TS-package-local** (e.g. `packages/codegen-ts/test/fixtures/docs/…`), not in the shared corpus; and |
| 91 | +- **relocates `template-source-conformance` + `template-source-conformance-package`** out of `fixtures/conformance/` into the same TS-local home, updating the `CORPUS`/`FIXTURE` constants in the TS gates that reference them. |
| 92 | + |
| 93 | +This removes the existing red and prevents SP-1 from adding more shared-corpus landmines. (Verify against the actually-failing port harness during implementation; the relocation must leave all TS docs gates green and stop the cross-port harnesses from globbing these input-only dirs.) |
| 94 | + |
| 95 | +## ADR |
| 96 | + |
| 97 | +A new ADR (next free number, ~ADR-0026) records the **polyglot docs composition contract**: model-once + per-language `apiSurfaces[]` (explicit-list, buf/Smithy-aligned), cross-link base relative-or-`baseUrl`, the deferred manifest/federation path, and the per-port-command/shared-model-engine principle inherited from ADR-0025. Add a pointer line from ADR-0025. |
| 98 | + |
| 99 | +## File structure (TS) |
| 100 | + |
| 101 | +- `packages/codegen-ts/src/metaobjects-config.ts` — `ApiSurface` type, `apiSurfaces` on `DocsConfig`/`ResolvedDocsConfig`, resolution in `resolveDocsConfig`. |
| 102 | +- `packages/codegen-ts/src/docs-paths.ts` — a cross-link-base helper (relative-or-baseUrl) over `surfaceCrossHref`. |
| 103 | +- `packages/codegen-ts/src/generators/docs-file.ts` + the entity doc-data builder + `templates/docs/entity-page.md.mustache` — `apiRefs[]` (generalize `apiPageHref`), the multi-link section (synced + byte-gated). |
| 104 | +- `packages/cli/src/commands/docs.ts` — resolve `apiSurfaces`; emit the owned api surface(s); render all declared links; the skip note. |
| 105 | +- `packages/codegen-ts/test/fixtures/docs/…` — relocated + new TS-local fixtures. |
| 106 | +- `packages/codegen-ts/test/golden/docs-cross-link-conformance.test.ts` (+ a new multi-surface gate) — N-surface integrity, flat+package+baseUrl+teeth; updated `CORPUS` paths. |
| 107 | +- `spec/decisions/ADR-0026-*.md` — the contract. |
| 108 | + |
| 109 | +## Testing / gates |
| 110 | + |
| 111 | +- `resolveDocsConfig` resolves `apiSurfaces` (config → resolved list); absent → single-surface default. |
| 112 | +- multi-surface cross-link integrity (flat + package), baseUrl federation case, teeth. |
| 113 | +- the default single-TS-surface case renders the `apiRefs` shape; goldens updated once to it. |
| 114 | +- all TS docs gates green after fixture relocation; the shared-corpus cross-port harnesses no longer glob the docs input-only fixtures. |
| 115 | + |
| 116 | +## YAGNI / non-goals |
| 117 | + |
| 118 | +- No Java (or other) api surface emit — that is SP-2. |
| 119 | +- No federation manifest path — deferred; only the source-agnostic seam. |
| 120 | +- No non-TS model-engine reach — not needed for polyglot (model comes from TS once). |
| 121 | +- No new model/api page CONTENT beyond the multi-link section. |
0 commit comments