Skip to content

Commit 3d7dcda

Browse files
dmealingclaude
andcommitted
feat(cli): meta docs emits owned api surfaces + links all declared (polyglot)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9c3610 commit 3d7dcda

2 files changed

Lines changed: 91 additions & 20 deletions

File tree

server/typescript/packages/cli/src/commands/docs.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
buildPkMap,
2424
buildRelationMap,
2525
resolveDocsConfig,
26+
apiLabel,
2627
} from "@metaobjectsdev/codegen-ts";
2728
import type {
2829
GenContext,
@@ -265,24 +266,33 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
265266
const emit: EmittedFile[] = [];
266267
let modelFiles: EmittedFile[] = [];
267268

268-
// Both surfaces emitted together → cross-link them. The api surface only
269-
// materializes with a loadable gen config, so guard on that too: when both are
270-
// requested AND the config loaded, the model pages cross-link to `api/` and the
271-
// api pages cross-link back to the model root. Otherwise each surface emits its
272-
// historical, byte-identical standalone output.
273-
const bothSurfaces =
274-
docsCfg.surfaces.includes("model") &&
275-
docsCfg.surfaces.includes("api") &&
276-
loadedConfig !== undefined;
269+
// The declared api surfaces, each tagged with its human label (apiLabel maps
270+
// the language key → label; never hardcode labels). The model page links one
271+
// reference per declared surface — across ALL ports, not just the surfaces
272+
// THIS command emits — so a polyglot model page points at every port's docs.
273+
const labeled = docsCfg.apiSurfaces.map((s) => ({ ...s, label: apiLabel(s.lang) }));
274+
275+
// The api surface only materializes with a loadable gen config (there is
276+
// nothing generated to document otherwise), so gate api emit + cross-linking
277+
// on that. When false, the model surface emits its historical standalone form.
278+
const apiSelected = docsCfg.surfaces.includes("api") && loadedConfig !== undefined;
277279

278280
// MODEL surface — the neutral metadata pages (<Entity>.md / <Template>.md +
279281
// README.md). Keep the render-error handling tight around docsFile() only.
280282
if (docsCfg.surfaces.includes("model")) {
283+
// When api is selected, link EVERY declared surface from the model page (so a
284+
// polyglot model page references each port's api docs). Otherwise no api refs.
285+
const modelOpts = apiSelected
286+
? {
287+
apiSurfaces: labeled.map(({ label, subDir, baseUrl }) => ({
288+
label,
289+
subDir,
290+
...(baseUrl ? { baseUrl } : {}),
291+
})),
292+
}
293+
: {};
281294
try {
282-
modelFiles = await (bothSurfaces
283-
? docsFile({ apiSurface: { subDir: "api" } })
284-
: docsFile()
285-
).generate(ctx);
295+
modelFiles = await docsFile(modelOpts).generate(ctx);
286296
} catch (err) {
287297
const msg = (err as Error).message;
288298
// Duplicate output path (silent-overwrite backstop): the generator already
@@ -311,16 +321,24 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
311321
}
312322

313323
// API surface — the SDK reference for the GENERATED REST surface, side by side
314-
// under api/. Only meaningful with a loadable gen config (there is nothing
315-
// generated to document otherwise), so skip gracefully when absent.
324+
// under each surface's subDir. THIS command only OWNS the surfaces it can
325+
// generate — i.e. its own port (lang "ts"). Surfaces owned by other ports are
326+
// linked (above) but produced by running that port's docs command; we just log
327+
// a pointer. Only meaningful with a loadable gen config, so skip when absent.
316328
let apiFiles: EmittedFile[] = [];
317329
if (docsCfg.surfaces.includes("api")) {
318330
if (loadedConfig !== undefined) {
319331
try {
320-
apiFiles = await apiDocsFile({
321-
subDir: "api",
322-
modelSurface: docsCfg.surfaces.includes("model"),
323-
}).generate(ctx);
332+
// Emit every surface THIS port owns (loop so it generalizes beyond the
333+
// single ts surface owned today).
334+
for (const s of labeled.filter((s) => s.lang === "ts")) {
335+
apiFiles.push(
336+
...(await apiDocsFile({
337+
subDir: s.subDir,
338+
modelSurface: docsCfg.surfaces.includes("model"),
339+
}).generate(ctx)),
340+
);
341+
}
324342
} catch (err) {
325343
const msg = (err as Error).message;
326344
if (msg.startsWith("docs: duplicate output path")) {
@@ -331,6 +349,13 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
331349
return 1;
332350
}
333351
emit.push(...apiFiles);
352+
// Surfaces owned by other ports: link only, with a pointer to where they
353+
// get produced.
354+
for (const s of labeled.filter((s) => s.lang !== "ts")) {
355+
log.info(
356+
`meta docs: api surface '${s.lang}' (${s.subDir}) is produced by that port's docs command — run it to populate those pages.`,
357+
);
358+
}
334359
} else if (hasConfig) {
335360
// Config present but failed to load — already warned above; don't claim an
336361
// api surface we couldn't build.

server/typescript/packages/cli/test/unit/docs-command-surfaces.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, test, expect, afterAll } from "bun:test";
22
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
3-
import { existsSync } from "node:fs";
3+
import { existsSync, readFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { docsCommand } from "../../src/commands/docs.js";
@@ -37,6 +37,25 @@ const CONFIG = [
3737
`});`,
3838
].join("\n");
3939

40+
// A config whose docs block declares TWO api surfaces: one this port owns
41+
// (lang "ts") and one another port owns (lang "java"). This command should emit
42+
// only the owned (ts) surface but link BOTH from the model page.
43+
const CONFIG_TWO_SURFACES = [
44+
`import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
45+
`import { entityFile } from "@metaobjectsdev/codegen-ts/generators";`,
46+
`export default defineConfig({`,
47+
` outDir: "out",`,
48+
` dialect: "sqlite",`,
49+
` generators: [entityFile()],`,
50+
` docs: {`,
51+
` apiSurfaces: [`,
52+
` { lang: "ts", subDir: "api/ts" },`,
53+
` { lang: "java", subDir: "api/java" },`,
54+
` ],`,
55+
` },`,
56+
`});`,
57+
].join("\n");
58+
4059
const dirs: string[] = [];
4160

4261
/** Project root with metadata + a metaobjects.config.ts present. */
@@ -49,6 +68,16 @@ async function projectWithConfig(): Promise<string> {
4968
return root;
5069
}
5170

71+
/** Project root whose docs config declares two api surfaces (ts + java). */
72+
async function projectTwoSurfaces(): Promise<string> {
73+
const root = await mkdtemp(join(tmpdir(), "meta-docs-surf-2-"));
74+
dirs.push(root);
75+
await mkdir(join(root, "metaobjects"), { recursive: true });
76+
await writeFile(join(root, "metaobjects", "meta.json"), JSON.stringify(META), "utf8");
77+
await writeFile(join(root, "metaobjects.config.ts"), CONFIG_TWO_SURFACES, "utf8");
78+
return root;
79+
}
80+
5281
/** Project root with metadata but NO metaobjects.config.ts. */
5382
async function projectNoConfig(): Promise<string> {
5483
const root = await mkdtemp(join(tmpdir(), "meta-docs-surf-nocfg-"));
@@ -88,6 +117,23 @@ describe("meta docs — model + api surfaces from one docs: config", () => {
88117
expect(existsSync(join(out, "api"))).toBe(false);
89118
});
90119

120+
test("emits only the owned (ts) surface but links ALL declared surfaces", async () => {
121+
const root = await projectTwoSurfaces();
122+
const out = join(root, "out-two");
123+
124+
const code = await docsCommand([root, "--out", out], root);
125+
expect(code).toBe(0);
126+
127+
// TS surface IS emitted (this port owns lang "ts"):
128+
expect(existsSync(join(out, "api", "ts", "Widget.md"))).toBe(true);
129+
// Java surface is NOT emitted by THIS command:
130+
expect(existsSync(join(out, "api", "java"))).toBe(false);
131+
// the model entity page links BOTH surfaces:
132+
const page = readFileSync(join(out, "Widget.md"), "utf8");
133+
expect(page).toContain("api/ts/");
134+
expect(page).toContain("api/java/");
135+
});
136+
91137
test("no config → model surface only, exit 0 (api skipped)", async () => {
92138
const root = await projectNoConfig();
93139
const out = join(root, "out-nocfg");

0 commit comments

Comments
 (0)