Skip to content

Commit 08e90a4

Browse files
dmealingclaude
andcommitted
Merge: meta docs binary templates + config providers (the two neutral-docs follow-ups)
Closes the two documented follow-ups from the neutral-metadata-docs feature so 'meta docs' is a fully standalone Tier-2 capability: 1. Framework doc templates now resolve inside the bun --compile meta binary. A generated plain-string module (embedded-templates.generated.ts, from the canonical templates/) is consulted as a fallback AFTER on-disk resolution, so dev/install layouts + adopter overrides are unaffected. tsc-safe; gated byte-identical to canonical (third representation alongside root + package copy). 2. meta docs loads metaobjects.config.ts providers (mirroring gen) so adopters with custom field/object types can generate docs — config stays OPTIONAL (config-less docs still work). A broken config is now surfaced with its real error instead of silently degrading. Also fixed a pre-existing shared config-loader bug (@metaobjectsdev/metadata missing from jiti's alias map), which affected gen/migrate/verify custom-provider configs too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 93e6120 + 3d1eae7 commit 08e90a4

9 files changed

Lines changed: 433 additions & 13 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# `meta docs` binary templates + config providers — implementation plan
2+
3+
> REQUIRED SUB-SKILL: superpowers:subagent-driven-development.
4+
5+
**Goal:** Close the two documented follow-ups from the neutral-metadata-docs feature so `meta docs` is a fully standalone Tier-2 capability:
6+
1. Framework doc templates resolve inside the `bun --compile` `meta` binary (today they don't — only adopter-provided `templates/` work there).
7+
2. `meta docs` loads `metaobjects.config.ts` providers so adopters with custom field/object types can generate docs.
8+
9+
**Architecture:** Both `codegen-ts` and `cli` build with `tsc` (the binary is built separately with `bun build --compile`). So embedding cannot use a `.mustache` text-import (tsc rejects it). Instead generate a plain-string module from the canonical `templates/`, imported by the framework provider with an on-disk-first / embedded-fallback chain. Config-provider loading mirrors `gen.ts`.
10+
11+
**Tech:** TypeScript, bun 1.3.8, tsc builds, the existing `render()`/`projectProvider` chain.
12+
13+
---
14+
15+
## Task 1: Embedded framework-template fallback (fixes binary)
16+
17+
**Intent:** `FrameworkTemplatesProvider` resolves the framework doc templates from the embedded map when the on-disk `templates/` dir is unavailable (the compiled binary). On-disk stays FIRST so dev/install layouts keep honoring local edits + adopter overrides.
18+
19+
**Files:**
20+
- Create `scripts/generate-embedded-templates.ts` (bun script) — reads canonical `templates/docs/*.mustache`, writes `server/typescript/packages/codegen-ts/src/render-engine/embedded-templates.generated.ts` exporting `export const EMBEDDED_FRAMEWORK_TEMPLATES: Record<string, string> = { "docs/<name>.md": <content>, ... }` (key = the ref WITHOUT `.mustache`, matching how refs resolve; value = exact file text). Plain string literals only (tsc-safe). Header `// @generated from templates/docs/*.mustache — DO NOT EDIT; run scripts/generate-embedded-templates.ts`.
21+
- Extend `scripts/sync-doc-templates.sh` to ALSO invoke the generator (so one command syncs the package copy AND regenerates the embedded module from canonical).
22+
- Modify `server/typescript/packages/codegen-ts/src/render-engine/framework-provider.ts``FrameworkTemplatesProvider.resolve(ref)`: keep the on-disk lookup first; if it returns undefined, return `EMBEDDED_FRAMEWORK_TEMPLATES[ref]` (or undefined). Update the stale comment (lines ~33-35) that references a non-existent embedded fallback to describe the real one.
23+
- Test: `server/typescript/packages/codegen-ts/test/embedded-templates.test.ts` — (a) GATE: every canonical `templates/docs/*.mustache` has a matching embedded entry whose content is byte-identical (prevents drift); (b) the embedded map covers exactly the canonical set (no missing/extra); (c) simulate the binary case: a `FrameworkTemplatesProvider` whose on-disk dir is unresolved still resolves `docs/entity-page.md` + `docs/template-page.md` from the embedded map.
24+
25+
- [ ] Step 1: Write `embedded-templates.test.ts` (drift gate + binary-fallback). Run → FAIL (generated module doesn't exist).
26+
- [ ] Step 2: Write `scripts/generate-embedded-templates.ts`; run it to produce `embedded-templates.generated.ts`. Wire it into `sync-doc-templates.sh`.
27+
- [ ] Step 3: Update `FrameworkTemplatesProvider.resolve` (on-disk → embedded fallback) + fix the stale comment.
28+
- [ ] Step 4: Run → PASS the new test + `bun test packages/codegen-ts` (existing docs/golden/byte-identity gates unaffected — on-disk still wins in dev). Report counts.
29+
- [ ] Step 5: Binary proof — `cd packages/cli && bun build ./bin/meta.ts --compile --outfile /tmp/meta-test --external @biomejs/wasm-bundler --external @biomejs/wasm-web`, then run `/tmp/meta-test docs <fixture-metadata> --out /tmp/docsout` from a dir with NO `templates/` and assert entity + template `.md` files are produced (framework templates now resolve from the embed). Report the result. (If building the full binary is too heavy/slow, at minimum assert the embedded provider resolves with on-disk forced-undefined — but attempt the real binary.)
30+
- [ ] Step 6: Commit. `feat(codegen-ts): embed framework doc templates so they resolve in the compiled binary`
31+
32+
## Task 2: `meta docs` loads config providers (fixes custom types)
33+
34+
**Intent:** `meta docs` loads `metaobjects.config.ts` (best-effort) and passes `providers` to `loadMemory`, so adopters with custom field/object types can generate docs — mirroring `gen.ts`.
35+
36+
**Files:**
37+
- Modify `server/typescript/packages/cli/src/commands/docs.ts` — before `loadMemory`, best-effort `loadMetaobjectsConfig(projectRoot)` (import from `../lib/load-metaobjects-config.js`, as gen.ts does); if it succeeds, pass `{ providers: forgeConfig.providers }` to `loadMemory` (guard undefined, exactly like `gen.ts:42-43`). If config loading fails/absent, proceed config-less (docs must still work with no config — do NOT hard-fail; gen treats config as required, docs must NOT).
38+
- Test: `server/typescript/packages/cli/test/docs-command.test.ts` — add a case: a fixture project WITH a `metaobjects.config.ts` (or the config-loading mechanism) declaring a custom field/object type used in its metadata; `meta docs` loads + emits docs without a "type does not resolve" failure. Also keep/confirm the existing no-config case still works (config-less).
39+
40+
- [ ] Step 1: Write the custom-type-with-config docs test (mirror how gen's config tests set up `loadMetaobjectsConfig`/a fixture config). Run → FAIL (docs currently ignores providers → custom type fails to load).
41+
- [ ] Step 2: Add best-effort config+providers loading to `docs.ts` (mirror gen.ts; non-fatal when absent).
42+
- [ ] Step 3: Run → PASS the new test + the existing no-config docs tests + `bun test packages/cli`. Report counts.
43+
- [ ] Step 4: Commit. `feat(cli): meta docs loads metaobjects.config.ts providers (custom types)`
44+
45+
## Task 3: Closeout
46+
- [ ] Full suites: `bun test packages/codegen-ts packages/cli packages/render` green; counts.
47+
- [ ] Hygiene (merge-base diff): no private names/home paths/node_modules/bunfig/lockfile/dist.
48+
- [ ] Whole-branch code-review + simplifier; fix findings.
49+
- [ ] Forward-merge to main, push, remove worktree, delete branch, update memory.
50+
51+
## Notes / guards
52+
- The embedded module is a GENERATED artifact gated against canonical `templates/` — edit root templates, run `sync-doc-templates.sh` (now also regenerates the embed). Three representations (root canonical, package bundled copy, embedded module) all gated byte-identical.
53+
- On-disk MUST stay first in `FrameworkTemplatesProvider` so adopter overrides + dev edits win; embedded is fallback only.
54+
- Don't touch native code generators, the render engine, other ports, or doc neutrality. Public-repo hygiene.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env bun
2+
//
3+
// generate-embedded-templates.ts
4+
//
5+
// Reads the canonical doc templates at repo-root templates/docs/*.mustache and
6+
// emits a plain-string TS module
7+
// server/typescript/packages/codegen-ts/src/render-engine/embedded-templates.generated.ts
8+
// mapping the provider resolve ref (path under templates/ WITHOUT the .mustache
9+
// suffix, e.g. "docs/entity-page.md") to the EXACT file contents.
10+
//
11+
// WHY a generated string module (not a `.mustache` text-import): codegen-ts
12+
// builds with `tsc`, which rejects unknown-extension text imports
13+
// (`import x from "./f.mustache" with { type: "text" }`). A plain TS module of
14+
// string literals compiles cleanly with tsc AND gets bundled into the
15+
// `bun build --compile` standalone `meta` binary — so the framework doc
16+
// templates resolve even where the on-disk templates/ dir is unavailable.
17+
//
18+
// Run via: bun run scripts/generate-embedded-templates.ts
19+
// (also wired into scripts/sync-doc-templates.sh so one command keeps the
20+
// package copy AND this embedded module in sync with canonical.)
21+
//
22+
// A byte-identity drift test gates this output:
23+
// server/typescript/packages/codegen-ts/test/embedded-templates.test.ts
24+
25+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
26+
import { dirname, join } from "node:path";
27+
import { fileURLToPath } from "node:url";
28+
29+
// Resolve the repo root relative to THIS script (walk up to the dir containing
30+
// both templates/ and server/). No hardcoded absolute paths.
31+
function findRepoRoot(start: string): string {
32+
let dir = start;
33+
while (true) {
34+
if (existsSync(join(dir, "templates")) && existsSync(join(dir, "server"))) {
35+
return dir;
36+
}
37+
const parent = dirname(dir);
38+
if (parent === dir) {
39+
throw new Error("Could not locate repo root (dir containing templates/ and server/)");
40+
}
41+
dir = parent;
42+
}
43+
}
44+
45+
const scriptDir = dirname(fileURLToPath(import.meta.url));
46+
const repoRoot = findRepoRoot(scriptDir);
47+
48+
const canonicalDir = join(repoRoot, "templates", "docs");
49+
const outFile = join(
50+
repoRoot,
51+
"server",
52+
"typescript",
53+
"packages",
54+
"codegen-ts",
55+
"src",
56+
"render-engine",
57+
"embedded-templates.generated.ts",
58+
);
59+
60+
const files = readdirSync(canonicalDir).filter((f) => f.endsWith(".mustache"));
61+
62+
if (files.length === 0) {
63+
console.error(`error: no *.mustache templates found under ${canonicalDir}`);
64+
process.exit(1);
65+
}
66+
67+
// ref = path under templates/ WITHOUT the .mustache suffix, matching how
68+
// FrameworkTemplatesProvider.resolve(ref) builds `<dir>/<ref>.mustache`.
69+
// e.g. templates/docs/entity-page.md.mustache -> "docs/entity-page.md".
70+
// Sorted by ref for deterministic, byte-stable output.
71+
const entries = files
72+
.map((file) => {
73+
const ref = `docs/${file.slice(0, -".mustache".length)}`;
74+
const content = readFileSync(join(canonicalDir, file), "utf-8");
75+
return { ref, content };
76+
})
77+
.sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
78+
79+
const body = entries
80+
// JSON.stringify emits a safe string literal — preserves newlines/quotes/
81+
// unicode byte-for-byte.
82+
.map((e) => ` ${JSON.stringify(e.ref)}: ${JSON.stringify(e.content)},`)
83+
.join("\n");
84+
85+
const source = `// @generated from templates/docs/*.mustache — DO NOT EDIT.
86+
// Regenerate: bun run scripts/generate-embedded-templates.ts (or scripts/sync-doc-templates.sh).
87+
//
88+
// Embedded framework doc templates so they resolve inside the
89+
// \`bun build --compile\` standalone \`meta\` binary, where the on-disk
90+
// \`templates/\` directory is unavailable. Keys are provider resolve refs
91+
// (path under templates/ minus the .mustache suffix).
92+
export const EMBEDDED_FRAMEWORK_TEMPLATES: Record<string, string> = {
93+
${body}
94+
};
95+
`;
96+
97+
writeFileSync(outFile, source, "utf-8");
98+
console.log(`generated ${outFile} (${entries.length} template(s): ${entries.map((e) => e.ref).join(", ")})`);

scripts/sync-doc-templates.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,10 @@ for dest in "${DESTS[@]}"; do
4949
done
5050
done
5151

52+
# Regenerate the embedded-string TS module so the framework doc templates also
53+
# resolve inside the `bun build --compile` standalone `meta` binary (where the
54+
# on-disk templates/ dir is unavailable). Keeps the bundled package copy AND the
55+
# embedded module in sync with canonical from a single command. Idempotent.
56+
bun run "${SCRIPT_DIR}/generate-embedded-templates.ts"
57+
5258
echo "doc templates synced from canonical root: ${SRC_DIR}"

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { resolve as resolvePath } from "node:path";
1515
import { mkdir, writeFile } from "node:fs/promises";
1616
import { log } from "../lib/log.js";
17+
import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
1718
import { loadMemory, DEFAULT_METADATA_DIR } from "@metaobjectsdev/sdk";
1819
import { existsSync } from "node:fs";
1920
import { join } from "node:path";
@@ -88,10 +89,40 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
8889
: metaRoot;
8990
const outDir = resolvePath(metaRoot, flags.out);
9091

91-
// Load metadata standalone — same loader path as migrate/gen, NO gen config.
92+
// Best-effort load of metaobjects.config.ts to pick up consumer-supplied
93+
// providers (e.g. a project's custom field/object subtypes). Unlike `gen`,
94+
// docs does NOT require a config — the Tier-2 "metadata alone" promise must
95+
// hold for config-less projects. If the config is absent or invalid, fall
96+
// back to defaults; the loader still surfaces a stable unknown-subtype error
97+
// if the metadata genuinely uses an unregistered type.
98+
let configProviders: NonNullable<Awaited<ReturnType<typeof loadMetaobjectsConfig>>["providers"]> | undefined;
99+
// The config lives alongside metaobjects/ at the metadata root (metaRoot);
100+
// projectRoot only diverges when --templates overrides the template lookup.
101+
// Only attempt the load when the file is actually present: absence is the
102+
// expected config-less case (stay silent), but a config that EXISTS yet fails
103+
// to load is surfaced as a warning rather than silently degrading to
104+
// provider-less docs — otherwise a custom-type project would later fail with a
105+
// cryptic unknown-subtype error instead of the real config error.
106+
if (existsSync(join(metaRoot, "metaobjects.config.ts"))) {
107+
try {
108+
const forgeConfig = await loadMetaobjectsConfig(metaRoot);
109+
configProviders = forgeConfig.providers;
110+
} catch (err) {
111+
log.warn(
112+
`docs: metaobjects.config.ts failed to load (${(err as Error).message}); ` +
113+
`generating docs without its providers`,
114+
);
115+
configProviders = undefined;
116+
}
117+
}
118+
119+
// Load metadata standalone — same loader path as migrate/gen. Threads any
120+
// consumer providers from the config so custom types resolve.
92121
let root;
93122
try {
94-
root = await loadMemory(metaRoot);
123+
root = await loadMemory(metaRoot, {
124+
...(configProviders !== undefined ? { providers: configProviders } : {}),
125+
});
95126
} catch (err) {
96127
const msg = (err as Error).message;
97128
if (!existsSync(join(metaRoot, DEFAULT_METADATA_DIR))) {

server/typescript/packages/cli/src/lib/load-metaobjects-config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ const CLI_PKG_PATHS: Record<string, { dist: string; src: string }> = {
3232
dist: "node_modules/@metaobjectsdev/codegen-ts/dist/index.js",
3333
src: "node_modules/@metaobjectsdev/codegen-ts/src/index.ts",
3434
},
35+
// Consumer configs that ship custom MetaDataTypeProviders import the type
36+
// primitives (TypeId, MetaField, TYPE_* …) from here. Aliased to the CLI's
37+
// own copy so the user's project needn't declare it as a direct dependency.
38+
"@metaobjectsdev/metadata": {
39+
dist: "node_modules/@metaobjectsdev/metadata/dist/index.js",
40+
src: "node_modules/@metaobjectsdev/metadata/src/index.ts",
41+
},
3542
"@metaobjectsdev/codegen-ts/generators": {
3643
dist: "node_modules/@metaobjectsdev/codegen-ts/dist/generators/index.js",
3744
src: "node_modules/@metaobjectsdev/codegen-ts/src/generators/index.ts",
@@ -94,6 +101,7 @@ export async function loadMetaobjectsConfig(projectRoot: string): Promise<Metaob
94101
interopDefault: true,
95102
alias: {
96103
"@metaobjectsdev/codegen-ts": resolveCliPkg("@metaobjectsdev/codegen-ts"),
104+
"@metaobjectsdev/metadata": resolveCliPkg("@metaobjectsdev/metadata"),
97105
"@metaobjectsdev/codegen-ts/generators": resolveCliPkg("@metaobjectsdev/codegen-ts/generators"),
98106
"@metaobjectsdev/codegen-ts-react": resolveCliPkg("@metaobjectsdev/codegen-ts-react"),
99107
"@metaobjectsdev/codegen-ts-tanstack": resolveCliPkg("@metaobjectsdev/codegen-ts-tanstack"),

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,67 @@ async function project(): Promise<string> {
5252
return root;
5353
}
5454

55+
// Metadata using a CUSTOM field subtype (`field.geopoint`) that only a
56+
// consumer-supplied provider in metaobjects.config.ts can resolve.
57+
const CUSTOM_META = {
58+
"metadata.root": {
59+
package: "acme::geo",
60+
children: [
61+
{
62+
"object.value": {
63+
name: "Place",
64+
children: [
65+
{ "field.string": { name: "name" } },
66+
// Resolves ONLY when the config's geopoint provider is loaded.
67+
{ "field.geopoint": { name: "location" } },
68+
],
69+
},
70+
},
71+
],
72+
},
73+
};
74+
75+
// A metaobjects.config.ts that registers the `field.geopoint` subtype via a
76+
// consumer provider (mirrors how an adopter ships custom types to gen/migrate).
77+
const CUSTOM_CONFIG = [
78+
`import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
79+
`import { entityFile } from "@metaobjectsdev/codegen-ts/generators";`,
80+
`import { TypeId, TYPE_FIELD, MetaField } from "@metaobjectsdev/metadata";`,
81+
`const geoProvider = {`,
82+
` id: "test-geo",`,
83+
` dependencies: ["metaobjects-core-types"],`,
84+
` registerTypes(registry) {`,
85+
` registry.register({`,
86+
` typeId: new TypeId(TYPE_FIELD, "geopoint"),`,
87+
` description: "A geographic point field",`,
88+
` factory: (typeId, name) => new MetaField(typeId, name),`,
89+
` childRules: [],`,
90+
` attributes: [],`,
91+
` });`,
92+
` },`,
93+
`};`,
94+
`export default defineConfig({`,
95+
` outDir: "out",`,
96+
` dialect: "sqlite",`,
97+
` generators: [entityFile()],`,
98+
` providers: [geoProvider],`,
99+
`});`,
100+
].join("\n");
101+
102+
/** Project root with metadata using a custom type + a config that provides it. */
103+
async function customTypeProject(): Promise<string> {
104+
const root = await mkdtemp(join(tmpdir(), "meta-docs-custom-"));
105+
dirs.push(root);
106+
await mkdir(join(root, "metaobjects"), { recursive: true });
107+
await writeFile(
108+
join(root, "metaobjects", "meta.json"),
109+
JSON.stringify(CUSTOM_META),
110+
"utf8",
111+
);
112+
await writeFile(join(root, "metaobjects.config.ts"), CUSTOM_CONFIG, "utf8");
113+
return root;
114+
}
115+
55116
afterAll(async () => {
56117
for (const d of dirs) await rm(d, { recursive: true, force: true });
57118
});
@@ -120,6 +181,55 @@ describe("meta docs — standalone neutral metadata docs", () => {
120181
expect(summary).toContain(out);
121182
});
122183

184+
test("loads metaobjects.config.ts providers so custom types resolve", async () => {
185+
const root = await customTypeProject();
186+
const out = join(root, "out-custom");
187+
188+
// The metadata uses field.geopoint, which is NOT a core subtype — it only
189+
// resolves via the provider declared in metaobjects.config.ts. Before docs
190+
// loaded the config providers, this load failed with an unknown-subtype
191+
// error and docs returned non-zero.
192+
const code = await docsCommand([root, "--out", out], root);
193+
expect(code).toBe(0);
194+
195+
const files = await readdir(out);
196+
expect(files).toContain("Place.md");
197+
});
198+
199+
test("a broken metaobjects.config.ts is surfaced (not silently swallowed), docs still generate", async () => {
200+
// Basic metadata (no custom types) + a config that THROWS on load. Docs must
201+
// still succeed config-less, but the real config error must be surfaced as a
202+
// warning rather than silently degrading to provider-less docs.
203+
const root = await project();
204+
await writeFile(
205+
join(root, "metaobjects.config.ts"),
206+
"throw new Error('broken config boom');\n",
207+
"utf8",
208+
);
209+
const out = join(root, "out-brokencfg");
210+
211+
const errLogged: string[] = [];
212+
const origErr = console.error;
213+
console.error = (...args: unknown[]) => {
214+
errLogged.push(args.map(String).join(" "));
215+
};
216+
let code: number;
217+
try {
218+
code = await docsCommand([root, "--out", out], root);
219+
} finally {
220+
console.error = origErr;
221+
}
222+
223+
expect(code).toBe(0); // config-less generation still works
224+
const files = await readdir(out);
225+
expect(files.length).toBeGreaterThan(0);
226+
// The config failure is SURFACED (warning on stderr), not silently swallowed,
227+
// and carries the loader's real diagnostic so the user can fix it.
228+
const stderr = errLogged.join("\n");
229+
expect(stderr).toContain("metaobjects.config.ts failed to load");
230+
expect(stderr).toContain("generating docs without its providers");
231+
});
232+
123233
test("exits non-zero with a clear message when metadata is missing", async () => {
124234
const empty = await mkdtemp(join(tmpdir(), "meta-docs-empty-"));
125235
dirs.push(empty);

0 commit comments

Comments
 (0)