Skip to content

Commit aad21fe

Browse files
dmealingclaude
andauthored
feat(docs-site): thread consumer providers into the --site surface (#172)
The markdown docs surfaces load metadata via the sdk loadMemory({ providers }), so `meta docs --model/--api` already honor a project's metaobjects.config.ts `providers` (custom field/view/object subtypes). The HTML `--site` surface has its OWN loader (docs-site's loadModel) with a hard-coded registry, so a model using a consumer subtype (e.g. a custom `view.*` on a field) failed there alone with `Unknown type "…" — not registered`, even though the config declared its provider and every other surface resolved it. - docs-site: loadModel(sourceDirs, extraProviders = []) composes the consumer providers AFTER the built-in bundle (core-types/db/doc/prompt/ui), mirroring loadMemory's `providers`; SiteOptions gains `extraProviders`; re-export MetaDataTypeProvider. Additive — default none, so config-less callers are unchanged (acme golden byte-identical). - cli: emitSite threads the configProviders the docs command already loads from metaobjects.config.ts into generateSite (both the site-only and the additive- with-markdown paths), so `meta docs --site` resolves custom subtypes the same way the markdown surfaces do. Tests: docs-site provider-extension (a custom field subtype fails to load without its provider, then resolves via extraProviders and renders a site); cli docs-command reuses the existing custom-type project fixture to prove `meta docs --site` now honors config providers. docs-site + cli typecheck clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a82212 commit aad21fe

6 files changed

Lines changed: 122 additions & 8 deletions

File tree

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
} from "@metaobjectsdev/codegen-ts";
3434
import { docsFile, apiDocsFile } from "@metaobjectsdev/codegen-ts/generators";
3535
import { composeRegistry, coreProviders, renderCoreMetamodelDocs } from "@metaobjectsdev/metadata";
36+
import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";
3637
import { generateSite, SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "@metaobjectsdev/docs-site";
3738

3839
type DocsLayout = "flat" | "package";
@@ -260,7 +261,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
260261
// WITHOUT building the markdown GenContext — decoupled and one fewer failure
261262
// surface. Combined with --model/--api it is emitted after them (below).
262263
if (flags.site && docsCfg.surfaces.length === 0) {
263-
return emitSite(metaRoot, outDir);
264+
return emitSite(metaRoot, outDir, configProviders);
264265
}
265266

266267
// Load metadata standalone — same loader path as migrate/gen. Threads any
@@ -427,7 +428,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
427428

428429
// SITE surface (additive) — emit after the markdown surfaces so both coexist.
429430
if (flags.site) {
430-
const siteRc = await emitSite(metaRoot, outDir);
431+
const siteRc = await emitSite(metaRoot, outDir, configProviders);
431432
if (siteRc !== 0) return siteRc;
432433
}
433434

@@ -496,7 +497,11 @@ async function scaffoldSiteCommand(metaRoot: string): Promise<number> {
496497
* templates/assets into `<metaRoot>/codegen/docs-site/` (via `--scaffold-site`),
497498
* those win over the bundled defaults.
498499
*/
499-
async function emitSite(metaRoot: string, outDir: string): Promise<number> {
500+
async function emitSite(
501+
metaRoot: string,
502+
outDir: string,
503+
configProviders?: readonly MetaDataTypeProvider[],
504+
): Promise<number> {
500505
const siteOutDir = resolvePath(outDir, "site");
501506
const sourceDirs = [join(metaRoot, DEFAULT_METADATA_DIR)];
502507
// Scaffold-and-own: when the consumer has copied templates/assets into
@@ -511,6 +516,10 @@ async function emitSite(metaRoot: string, outDir: string): Promise<number> {
511516
stamp: new Date().toISOString().slice(0, 10),
512517
commit: "",
513518
core: { n: 15 },
519+
// Thread any consumer providers from metaobjects.config.ts so the site's
520+
// own loader resolves custom field/view/object subtypes — same providers
521+
// the markdown surfaces get via loadMemory.
522+
...(configProviders !== undefined ? { extraProviders: configProviders } : {}),
514523
...(existsSync(ownedTemplates) ? { templatesDir: ownedTemplates } : {}),
515524
...(existsSync(ownedAssets) ? { assetsDir: ownedAssets } : {}),
516525
});

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,19 @@ describe("meta docs --site — HTML documentation site", () => {
437437
const b = await readFile(join(out2, "site", "index.html"), "utf8");
438438
expect(a).toBe(b);
439439
});
440+
441+
test("--site threads metaobjects.config.ts providers so custom-subtype metadata renders", async () => {
442+
const root = await customTypeProject();
443+
const out = join(root, "out-site-custom");
444+
445+
// Place uses field.geopoint, resolvable ONLY via the provider declared in
446+
// metaobjects.config.ts. The site surface has its OWN loader (docs-site's
447+
// loadModel); before it threaded the config providers, this rejected the
448+
// unknown subtype and returned non-zero.
449+
const code = await docsCommand([root, "--site", "--out", out], root);
450+
expect(code).toBe(0);
451+
expect(existsSync(join(out, "site", "index.html"))).toBe(true);
452+
});
440453
});
441454

442455
describe("meta docs --scaffold-site — own your theme", () => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { generateSite } from "./site.js";
22
export type { SiteOptions, SiteResult } from "./site.js";
33
export { SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "./scaffold.js";
4+
export type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";

server/typescript/packages/docs-site/src/load.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@ import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { basename, join, resolve } from "node:path";
44
import { MetaDataLoader, composeRegistry, coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider } from "@metaobjectsdev/metadata";
5-
import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata";
5+
import type { MetaData, MetaRoot, MetaDataTypeProvider } from "@metaobjectsdev/metadata";
66

77
export interface LoadedModel {
88
root: MetaRoot;
99
warnings: string[];
1010
sourceDirs: string[];
1111
}
1212

13-
/** Load N metadata source dirs into ONE root via a staging dir of symlinks. */
14-
export async function loadModel(sourceDirs: string[]): Promise<LoadedModel> {
13+
/**
14+
* Load N metadata source dirs into ONE root via a staging dir of symlinks.
15+
*
16+
* `extraProviders` are consumer-supplied metamodel providers, composed AFTER
17+
* the built-in bundle (core-types + db + doc + prompt + ui) — mirroring
18+
* `loadMemory`'s `providers` option — so a site can document metadata that uses
19+
* custom field/view/object subtypes (e.g. a project's `metaobjects.config.ts`
20+
* `providers`). Defaults to none, so config-less callers are unchanged.
21+
*/
22+
export async function loadModel(
23+
sourceDirs: string[],
24+
extraProviders: readonly MetaDataTypeProvider[] = [],
25+
): Promise<LoadedModel> {
1526
const staging = mkdtempSync(join(tmpdir(), "metadocs-"));
1627
try {
1728
const usedBasenames = new Set<string>();
@@ -23,7 +34,7 @@ export async function loadModel(sourceDirs: string[]): Promise<LoadedModel> {
2334
usedBasenames.add(baseName);
2435
symlinkSync(resolve(dir), join(staging, baseName));
2536
}
26-
const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider]);
37+
const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider, ...extraProviders]);
2738
const result = await MetaDataLoader.fromDirectory(staging, { registry, strict: false });
2839
if (result.errors.length > 0) {
2940
throw new Error(`metadata load failed:\n${result.errors.map((e) => String(e)).join("\n")}`);

server/typescript/packages/docs-site/src/site.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
import { dirname, join, resolve } from "node:path";
33
import { fileURLToPath } from "node:url";
44
import { render, InMemoryProvider } from "@metaobjectsdev/render";
5+
import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";
56
import { loadModel } from "./load.js";
67
import { LinkGraph, fqnOf } from "./link-graph.js";
78
import { CoverageTracker } from "./coverage.js";
@@ -34,6 +35,10 @@ export interface SiteOptions {
3435
templatesDir?: string;
3536
/** Override dir for assets; if a file of the same basename exists here, it wins over the bundled assets/ dir. */
3637
assetsDir?: string | undefined;
38+
/** Consumer-supplied metamodel providers, composed AFTER the built-in bundle
39+
* (core-types + db + doc + prompt + ui) so a site can document metadata that
40+
* uses custom field/view/object subtypes. Mirrors loadMemory's `providers`. */
41+
extraProviders?: readonly MetaDataTypeProvider[];
3742
}
3843

3944
export interface SiteResult {
@@ -131,7 +136,7 @@ function buildNavHtml(
131136

132137
export async function generateSite(opts: SiteOptions): Promise<SiteResult> {
133138
// 1. Load + graph + comments
134-
const loaded = await loadModel(opts.sourceDirs);
139+
const loaded = await loadModel(opts.sourceDirs, opts.extraProviders);
135140
const g = new LinkGraph(loaded);
136141
const docs = harvestComments(opts.sourceDirs);
137142
const cov = new CoverageTracker();
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect, test } from "bun:test";
2+
import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { TypeId, TYPE_FIELD, MetaField } from "@metaobjectsdev/metadata";
6+
import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";
7+
import { generateSite } from "../src/site";
8+
import { loadModel } from "../src/load";
9+
10+
// A consumer-supplied provider registering a custom field subtype the built-in
11+
// bundle (core-types/db/doc/prompt/ui) does not know — mirrors how an adopter
12+
// ships custom vocabulary via metaobjects.config.ts `providers`.
13+
function geoProvider(): MetaDataTypeProvider {
14+
return {
15+
id: "test-geo",
16+
dependencies: ["metaobjects-core-types"],
17+
registerTypes(registry) {
18+
registry.register({
19+
typeId: new TypeId(TYPE_FIELD, "geopoint"),
20+
description: "A geographic point field",
21+
factory: (typeId, name) => new MetaField(typeId, name),
22+
childRules: [],
23+
attributes: [],
24+
});
25+
},
26+
};
27+
}
28+
29+
// Metadata whose `field.geopoint` resolves ONLY when geoProvider is registered.
30+
function customTypeDir(): string {
31+
const root = mkdtempSync(join(tmpdir(), "docs-extra-prov-"));
32+
const acme = join(root, "acme");
33+
mkdirSync(acme, { recursive: true });
34+
writeFileSync(
35+
join(acme, "place.yaml"),
36+
[
37+
"metadata:",
38+
" package: acme::geo",
39+
" children:",
40+
" - object.value:",
41+
" name: Place",
42+
" children:",
43+
" - field.string: { name: name }",
44+
" - field.geopoint: { name: location }",
45+
"",
46+
].join("\n"),
47+
"utf8",
48+
);
49+
return acme;
50+
}
51+
52+
test("loadModel: a custom subtype fails without its provider, resolves with extraProviders", async () => {
53+
const dir = customTypeDir();
54+
// Without the provider the loader rejects the unknown subtype (the exact
55+
// failure a consumer hit running docs over metadata with custom view/field types).
56+
await expect(loadModel([dir])).rejects.toThrow(/geopoint|not registered|Unknown type/i);
57+
// Threading the provider through extraProviders resolves it.
58+
const model = await loadModel([dir], [geoProvider()]);
59+
expect(model.root.objects().map((o) => o.name)).toContain("Place");
60+
});
61+
62+
test("generateSite: extraProviders lets a site document custom-subtype metadata", async () => {
63+
const dir = customTypeDir();
64+
const out = mkdtempSync(join(tmpdir(), "docs-extra-out-"));
65+
const r = await generateSite({
66+
sourceDirs: [dir],
67+
outDir: out,
68+
title: "Fixture",
69+
stamp: "2026-01-01",
70+
commit: "abc1234",
71+
extraProviders: [geoProvider()],
72+
});
73+
expect(existsSync(join(out, "index.html"))).toBe(true);
74+
expect(r.dangling).toEqual([]);
75+
});

0 commit comments

Comments
 (0)