Skip to content

Commit 5fc844b

Browse files
dmealingclaude
andcommitted
feat(cli): meta docs — standalone neutral metadata-docs command
Add a standalone `meta docs <metadata> --out <dir>` command that emits neutral metadata documentation (one `<Entity>.md` per entity + one `<Template>.md` per template.output) from metadata ALONE — no gen config. It loads metadata via the same loadMemory path as migrate/gen, builds the same GenContext the codegen runner builds for the docsFile() generator, and writes the generator's output — so the pages are byte-for-byte the same (neutral) artifact the gen pipeline produces (DRY: no re-walk of objects/templates). Framework templates resolve from an on-disk templates/ dir (source/install layout), matching the codegen docs path. In the compiled standalone binary the framework templates are unresolvable (the bun virtual fs has no on-disk templates/), so docs surfaces a clear actionable error rather than crashing — same scope as gen's docs path (the binary targets schema ops). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b703adf commit 5fc844b

4 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// `meta docs <metadata> --out <dir>` — STANDALONE neutral metadata docs.
2+
//
3+
// Emits one neutral page per entity (`<Entity>.md`) and one per
4+
// `template.output` (`<Template>.md`) from metadata ALONE — no gen config, no
5+
// codegen pipeline. It is the Tier-2 delivery (like `migrate`): runnable from
6+
// the compiled `meta` binary.
7+
//
8+
// DRY: this command does NOT re-walk objects/templates. It builds the same
9+
// GenContext the codegen runner builds for the `docsFile()` generator and
10+
// calls that generator, then writes the returned files. The neutrality of the
11+
// output is therefore guaranteed — it is byte-for-byte the same generator the
12+
// `meta gen` pipeline runs (gated by the docs conformance fixture).
13+
14+
import { resolve as resolvePath } from "node:path";
15+
import { mkdir, writeFile } from "node:fs/promises";
16+
import { log } from "../lib/log.js";
17+
import { loadMemory, DEFAULT_METADATA_DIR } from "@metaobjectsdev/sdk";
18+
import { existsSync } from "node:fs";
19+
import { join } from "node:path";
20+
import {
21+
makeRenderContext,
22+
buildPkMap,
23+
buildRelationMap,
24+
} from "@metaobjectsdev/codegen-ts";
25+
import type { GenContext } from "@metaobjectsdev/codegen-ts";
26+
import { docsFile } from "@metaobjectsdev/codegen-ts/generators";
27+
28+
interface DocsFlags {
29+
/** Project root holding `metaobjects/` (the metadata to document). */
30+
metadata: string;
31+
/** Output directory for the rendered pages. */
32+
out: string;
33+
/** Optional override for the project root used to resolve adopter
34+
* `templates/` overrides. Defaults to the metadata root. */
35+
templates?: string;
36+
}
37+
38+
function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
39+
let metadata: string | undefined;
40+
let out: string | undefined;
41+
let templates: string | undefined;
42+
for (let i = 0; i < argv.length; i++) {
43+
const a = argv[i]!;
44+
if (a === "--out" || a === "-o") {
45+
const v = argv[++i];
46+
if (v === undefined) throw new Error(`${a} requires a directory argument`);
47+
out = v;
48+
} else if (a.startsWith("--out=")) {
49+
out = a.slice("--out=".length);
50+
} else if (a === "--templates") {
51+
const v = argv[++i];
52+
if (v === undefined) throw new Error(`${a} requires a directory argument`);
53+
templates = v;
54+
} else if (a.startsWith("--templates=")) {
55+
templates = a.slice("--templates=".length);
56+
} else if (a.startsWith("-")) {
57+
throw new Error(`unknown flag: ${a}`);
58+
} else if (metadata === undefined) {
59+
metadata = a;
60+
} else {
61+
throw new Error(`unexpected argument: ${a}`);
62+
}
63+
}
64+
return {
65+
// `<metadata>` is the project root that contains metaobjects/; default cwd
66+
// (mirrors how migrate/gen treat the working directory as the root).
67+
metadata: metadata ?? cwd,
68+
// Default out dir, resolved against the metadata root below.
69+
out: out ?? "./docs",
70+
...(templates !== undefined ? { templates } : {}),
71+
};
72+
}
73+
74+
export async function docsCommand(args: string[], cwd: string): Promise<number> {
75+
let flags: DocsFlags;
76+
try {
77+
flags = parseDocsArgs(args, cwd);
78+
} catch (err) {
79+
log.error(`docs: ${(err as Error).message}`);
80+
return 2;
81+
}
82+
83+
const metaRoot = resolvePath(cwd, flags.metadata);
84+
// The project root used to resolve adopter `templates/` overrides; the
85+
// framework defaults sit underneath via projectProvider's chain.
86+
const projectRoot = flags.templates !== undefined
87+
? resolvePath(cwd, flags.templates)
88+
: metaRoot;
89+
const outDir = resolvePath(metaRoot, flags.out);
90+
91+
// Load metadata standalone — same loader path as migrate/gen, NO gen config.
92+
let root;
93+
try {
94+
root = await loadMemory(metaRoot);
95+
} catch (err) {
96+
const msg = (err as Error).message;
97+
if (!existsSync(join(metaRoot, DEFAULT_METADATA_DIR))) {
98+
log.error(`docs: no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
99+
} else {
100+
log.error(`docs: failed to load metadata: ${msg}`);
101+
}
102+
return 2;
103+
}
104+
105+
// Build the same GenContext the codegen runner builds for docsFile(). The
106+
// dialect only affects column-type hints on the entity page; "sqlite" is the
107+
// neutral default (migrate's offline path uses the same fallback chain).
108+
const renderContext = makeRenderContext({
109+
dialect: "sqlite",
110+
loadedRoot: root,
111+
outDir,
112+
dbImport: "",
113+
pkMap: buildPkMap(root),
114+
relationMap: buildRelationMap(root),
115+
});
116+
const ctx: GenContext = {
117+
entities: root.objects(),
118+
loadedRoot: root,
119+
matches: () => true,
120+
config: { outDir, extStyle: "none", dbImport: "", dialect: "sqlite" } as never,
121+
renderContext,
122+
projectRoot,
123+
warn: (msg) => log.warn(`docs: ${msg}`),
124+
};
125+
126+
let files;
127+
try {
128+
files = await docsFile().generate(ctx);
129+
} catch (err) {
130+
const msg = (err as Error).message;
131+
// The framework templates resolve from disk; inside the compiled `meta`
132+
// binary they live on a virtual fs the provider cannot read. Surface that
133+
// as an actionable message rather than a cryptic render failure.
134+
if (/entity-page|template-page|failed rendering|ENOENT|not found/i.test(msg)) {
135+
log.error(
136+
`docs: failed to render — templates not found. ` +
137+
`Run 'meta docs' from an installed package layout (with on-disk ` +
138+
`templates/), not the standalone binary, OR drop your own ` +
139+
`templates/docs/entity-page.md.mustache + template-page.md.mustache. (${msg})`,
140+
);
141+
} else {
142+
log.error(`docs: ${msg}`);
143+
}
144+
return 1;
145+
}
146+
147+
try {
148+
await mkdir(outDir, { recursive: true });
149+
for (const f of files) {
150+
const path = resolvePath(outDir, f.path);
151+
await mkdir(resolvePath(path, ".."), { recursive: true });
152+
await writeFile(path, f.content, "utf8");
153+
}
154+
} catch (err) {
155+
log.error(`docs: failed to write pages: ${(err as Error).message}`);
156+
return 1;
157+
}
158+
159+
// Summary: docsFile() emits one page per entity first, then one per
160+
// template.output. The entity count is the matched object count; the rest
161+
// are template pages.
162+
const entityCount = root.objects().filter(ctx.matches).length;
163+
const templateCount = files.length - entityCount;
164+
log.info(
165+
`meta docs — wrote ${entityCount} entity page(s) + ${templateCount} template page(s) → ${outDir}`,
166+
);
167+
return 0;
168+
}

server/typescript/packages/cli/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ COMMANDS:
3939
init --refresh-docs Refresh .metaobjects/AGENTS.md + CLAUDE.md after CLI upgrades
4040
gen [<entity>...] Codegen TS targets from metaobjects/ entities
4141
export Flatten loaded metadata to one canonical JSON artifact
42+
docs <metadata> --out <dir> Generate neutral metadata documentation (entity + template pages)
4243
verify Check template.* text against its payload (drift gate)
4344
prompt-snapshot Snapshot rendered template.* output; --check gates drift
4445
migrate Diff metadata vs live DB; emit migration SQL files
@@ -56,6 +57,11 @@ GEN FLAGS:
5657
EXPORT FLAGS:
5758
--out <file> Write output to a file (default: stdout)
5859
60+
DOCS FLAGS:
61+
<metadata> Project root holding metaobjects/ (default: current directory)
62+
--out <dir>, -o Output directory for the pages (default: ./docs)
63+
--templates <dir> Project root to resolve adopter templates/ overrides (default: <metadata>)
64+
5965
VERIFY FLAGS:
6066
--prompts <dir> Directory of provider-resolved template text (default: prompts)
6167
--db <url> Live DB URL — enables the schema-drift gate (exit 1 on drift).
@@ -140,6 +146,10 @@ export async function run(argv: string[]): Promise<number> {
140146
const { exportCommand } = await import("./commands/export.js");
141147
return exportCommand(rest, cwd);
142148
}
149+
case "docs": {
150+
const { docsCommand } = await import("./commands/docs.js");
151+
return docsCommand(rest, cwd);
152+
}
143153
case "verify": {
144154
const { verifyCommand } = await import("./commands/verify.js");
145155
return verifyCommand(rest, cwd);

server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ COMMANDS:
1111
init --refresh-docs Refresh .metaobjects/AGENTS.md + CLAUDE.md after CLI upgrades
1212
gen [<entity>...] Codegen TS targets from metaobjects/ entities
1313
export Flatten loaded metadata to one canonical JSON artifact
14+
docs <metadata> --out <dir> Generate neutral metadata documentation (entity + template pages)
1415
verify Check template.* text against its payload (drift gate)
1516
prompt-snapshot Snapshot rendered template.* output; --check gates drift
1617
migrate Diff metadata vs live DB; emit migration SQL files
@@ -28,6 +29,11 @@ GEN FLAGS:
2829
EXPORT FLAGS:
2930
--out <file> Write output to a file (default: stdout)
3031
32+
DOCS FLAGS:
33+
<metadata> Project root holding metaobjects/ (default: current directory)
34+
--out <dir>, -o Output directory for the pages (default: ./docs)
35+
--templates <dir> Project root to resolve adopter templates/ overrides (default: <metadata>)
36+
3137
VERIFY FLAGS:
3238
--prompts <dir> Directory of provider-resolved template text (default: prompts)
3339
--db <url> Live DB URL — enables the schema-drift gate (exit 1 on drift).
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, test, expect, afterAll, beforeEach, afterEach } from "bun:test";
2+
import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from "node:fs/promises";
3+
import { existsSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { docsCommand } from "../src/commands/docs.js";
7+
import { run } from "../src/index.js";
8+
9+
// Metadata with ONE entity (object.value Welcome) + ONE template.output
10+
// (WelcomePage). Mirrors fixtures/conformance/template-doc-document so the
11+
// emitted pages exercise both the entity-page and the template-page paths.
12+
const META = {
13+
"metadata.root": {
14+
package: "acme::site",
15+
children: [
16+
{
17+
"object.value": {
18+
name: "Welcome",
19+
children: [
20+
{ "field.string": { name: "name" } },
21+
{ "field.string": { name: "headline" } },
22+
],
23+
},
24+
},
25+
{
26+
"template.output": {
27+
name: "WelcomePage",
28+
"@kind": "document",
29+
"@payloadRef": "Welcome",
30+
"@textRef": "site/welcome",
31+
"@format": "html",
32+
"@maxChars": 5000,
33+
"@requiredTags": ["section"],
34+
},
35+
},
36+
],
37+
},
38+
};
39+
40+
const dirs: string[] = [];
41+
42+
/** Build a standalone project root holding metaobjects/ — NO gen config. */
43+
async function project(): Promise<string> {
44+
const root = await mkdtemp(join(tmpdir(), "meta-docs-"));
45+
dirs.push(root);
46+
await mkdir(join(root, "metaobjects"), { recursive: true });
47+
await writeFile(
48+
join(root, "metaobjects", "meta.json"),
49+
JSON.stringify(META),
50+
"utf8",
51+
);
52+
return root;
53+
}
54+
55+
afterAll(async () => {
56+
for (const d of dirs) await rm(d, { recursive: true, force: true });
57+
});
58+
59+
// Capture stdout so we can assert the summary line.
60+
let logged: string[];
61+
let origLog: typeof console.log;
62+
beforeEach(() => {
63+
logged = [];
64+
origLog = console.log;
65+
console.log = (...args: unknown[]) => {
66+
logged.push(args.map(String).join(" "));
67+
};
68+
});
69+
afterEach(() => {
70+
console.log = origLog;
71+
});
72+
73+
describe("meta docs — standalone neutral metadata docs", () => {
74+
test("emits an entity page AND a template page from metadata alone", async () => {
75+
const root = await project();
76+
const out = join(root, "out-docs");
77+
78+
// No gen config present anywhere — proves this is metadata-only.
79+
expect(existsSync(join(root, "metaobjects.config.ts"))).toBe(false);
80+
expect(existsSync(join(root, ".metaobjects", "config.json"))).toBe(false);
81+
82+
const code = await docsCommand([root, "--out", out], root);
83+
expect(code).toBe(0);
84+
85+
const files = (await readdir(out)).sort();
86+
expect(files).toContain("Welcome.md");
87+
expect(files).toContain("WelcomePage.md");
88+
});
89+
90+
test("entity + template pages are NEUTRAL (no codegen leakage)", async () => {
91+
const root = await project();
92+
const out = join(root, "out-neutral");
93+
expect(await docsCommand([root, "--out", out], root)).toBe(0);
94+
95+
const entity = await readFile(join(out, "Welcome.md"), "utf8");
96+
const template = await readFile(join(out, "WelcomePage.md"), "utf8");
97+
98+
// Entity page is the neutral metadata contract.
99+
expect(entity).toContain("## Constraints");
100+
// Template page declares its render contract.
101+
expect(template).toContain("**Kind:**");
102+
103+
// Neutral: no language/toolchain leakage in either page.
104+
for (const page of [entity, template]) {
105+
expect(page).not.toContain("Zod");
106+
expect(page).not.toContain(".ts");
107+
expect(page).not.toContain("## Generated code");
108+
}
109+
});
110+
111+
test("prints a summary of pages written to --out", async () => {
112+
const root = await project();
113+
const out = join(root, "out-summary");
114+
expect(await docsCommand([root, "--out", out], root)).toBe(0);
115+
116+
const summary = logged.join("\n");
117+
// Mentions counts + destination.
118+
expect(summary).toMatch(/1 entity/);
119+
expect(summary).toMatch(/1 template/);
120+
expect(summary).toContain(out);
121+
});
122+
123+
test("exits non-zero with a clear message when metadata is missing", async () => {
124+
const empty = await mkdtemp(join(tmpdir(), "meta-docs-empty-"));
125+
dirs.push(empty);
126+
const out = join(empty, "out");
127+
const code = await docsCommand([empty, "--out", out], empty);
128+
expect(code).not.toBe(0);
129+
});
130+
131+
test("docs is registered in the top-level dispatcher", async () => {
132+
const root = await project();
133+
const out = join(root, "out-dispatch");
134+
// run() dispatches the docs command (would return 2 'Unknown command'
135+
// if it were not registered).
136+
const code = await run(["docs", root, "--out", out]);
137+
expect(code).toBe(0);
138+
expect(existsSync(join(out, "Welcome.md"))).toBe(true);
139+
});
140+
141+
test("--help lists docs", async () => {
142+
expect(await run(["--help"])).toBe(0);
143+
const help = logged.join("\n");
144+
expect(help).toContain("docs");
145+
});
146+
});

0 commit comments

Comments
 (0)