diff --git a/server/typescript/packages/cli/src/commands/docs.ts b/server/typescript/packages/cli/src/commands/docs.ts index 1cbd70023..695581538 100644 --- a/server/typescript/packages/cli/src/commands/docs.ts +++ b/server/typescript/packages/cli/src/commands/docs.ts @@ -33,6 +33,7 @@ import type { } from "@metaobjectsdev/codegen-ts"; import { docsFile, apiDocsFile } from "@metaobjectsdev/codegen-ts/generators"; import { composeRegistry, coreProviders, renderCoreMetamodelDocs } from "@metaobjectsdev/metadata"; +import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata"; import { generateSite, SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "@metaobjectsdev/docs-site"; type DocsLayout = "flat" | "package"; @@ -260,7 +261,7 @@ export async function docsCommand(args: string[], cwd: string): Promise // WITHOUT building the markdown GenContext — decoupled and one fewer failure // surface. Combined with --model/--api it is emitted after them (below). if (flags.site && docsCfg.surfaces.length === 0) { - return emitSite(metaRoot, outDir); + return emitSite(metaRoot, outDir, configProviders); } // Load metadata standalone — same loader path as migrate/gen. Threads any @@ -427,7 +428,7 @@ export async function docsCommand(args: string[], cwd: string): Promise // SITE surface (additive) — emit after the markdown surfaces so both coexist. if (flags.site) { - const siteRc = await emitSite(metaRoot, outDir); + const siteRc = await emitSite(metaRoot, outDir, configProviders); if (siteRc !== 0) return siteRc; } @@ -496,7 +497,11 @@ async function scaffoldSiteCommand(metaRoot: string): Promise { * templates/assets into `/codegen/docs-site/` (via `--scaffold-site`), * those win over the bundled defaults. */ -async function emitSite(metaRoot: string, outDir: string): Promise { +async function emitSite( + metaRoot: string, + outDir: string, + configProviders?: readonly MetaDataTypeProvider[], +): Promise { const siteOutDir = resolvePath(outDir, "site"); const sourceDirs = [join(metaRoot, DEFAULT_METADATA_DIR)]; // Scaffold-and-own: when the consumer has copied templates/assets into @@ -511,6 +516,10 @@ async function emitSite(metaRoot: string, outDir: string): Promise { stamp: new Date().toISOString().slice(0, 10), commit: "", core: { n: 15 }, + // Thread any consumer providers from metaobjects.config.ts so the site's + // own loader resolves custom field/view/object subtypes — same providers + // the markdown surfaces get via loadMemory. + ...(configProviders !== undefined ? { extraProviders: configProviders } : {}), ...(existsSync(ownedTemplates) ? { templatesDir: ownedTemplates } : {}), ...(existsSync(ownedAssets) ? { assetsDir: ownedAssets } : {}), }); diff --git a/server/typescript/packages/cli/test/docs-command.test.ts b/server/typescript/packages/cli/test/docs-command.test.ts index 044dac357..8a6dbf28e 100644 --- a/server/typescript/packages/cli/test/docs-command.test.ts +++ b/server/typescript/packages/cli/test/docs-command.test.ts @@ -437,6 +437,19 @@ describe("meta docs --site — HTML documentation site", () => { const b = await readFile(join(out2, "site", "index.html"), "utf8"); expect(a).toBe(b); }); + + test("--site threads metaobjects.config.ts providers so custom-subtype metadata renders", async () => { + const root = await customTypeProject(); + const out = join(root, "out-site-custom"); + + // Place uses field.geopoint, resolvable ONLY via the provider declared in + // metaobjects.config.ts. The site surface has its OWN loader (docs-site's + // loadModel); before it threaded the config providers, this rejected the + // unknown subtype and returned non-zero. + const code = await docsCommand([root, "--site", "--out", out], root); + expect(code).toBe(0); + expect(existsSync(join(out, "site", "index.html"))).toBe(true); + }); }); describe("meta docs --scaffold-site — own your theme", () => { diff --git a/server/typescript/packages/docs-site/src/index.ts b/server/typescript/packages/docs-site/src/index.ts index 318904da4..06ce1064a 100644 --- a/server/typescript/packages/docs-site/src/index.ts +++ b/server/typescript/packages/docs-site/src/index.ts @@ -1,3 +1,4 @@ export { generateSite } from "./site.js"; export type { SiteOptions, SiteResult } from "./site.js"; export { SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "./scaffold.js"; +export type { MetaDataTypeProvider } from "@metaobjectsdev/metadata"; diff --git a/server/typescript/packages/docs-site/src/load.ts b/server/typescript/packages/docs-site/src/load.ts index 4d4a077c5..bd32dc003 100644 --- a/server/typescript/packages/docs-site/src/load.ts +++ b/server/typescript/packages/docs-site/src/load.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { MetaDataLoader, composeRegistry, coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider } from "@metaobjectsdev/metadata"; -import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata"; +import type { MetaData, MetaRoot, MetaDataTypeProvider } from "@metaobjectsdev/metadata"; export interface LoadedModel { root: MetaRoot; @@ -10,8 +10,19 @@ export interface LoadedModel { sourceDirs: string[]; } -/** Load N metadata source dirs into ONE root via a staging dir of symlinks. */ -export async function loadModel(sourceDirs: string[]): Promise { +/** + * Load N metadata source dirs into ONE root via a staging dir of symlinks. + * + * `extraProviders` are consumer-supplied metamodel providers, composed AFTER + * the built-in bundle (core-types + db + doc + prompt + ui) — mirroring + * `loadMemory`'s `providers` option — so a site can document metadata that uses + * custom field/view/object subtypes (e.g. a project's `metaobjects.config.ts` + * `providers`). Defaults to none, so config-less callers are unchanged. + */ +export async function loadModel( + sourceDirs: string[], + extraProviders: readonly MetaDataTypeProvider[] = [], +): Promise { const staging = mkdtempSync(join(tmpdir(), "metadocs-")); try { const usedBasenames = new Set(); @@ -23,7 +34,7 @@ export async function loadModel(sourceDirs: string[]): Promise { usedBasenames.add(baseName); symlinkSync(resolve(dir), join(staging, baseName)); } - const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider]); + const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider, ...extraProviders]); const result = await MetaDataLoader.fromDirectory(staging, { registry, strict: false }); if (result.errors.length > 0) { throw new Error(`metadata load failed:\n${result.errors.map((e) => String(e)).join("\n")}`); diff --git a/server/typescript/packages/docs-site/src/site.ts b/server/typescript/packages/docs-site/src/site.ts index 9d498509a..7a080124e 100644 --- a/server/typescript/packages/docs-site/src/site.ts +++ b/server/typescript/packages/docs-site/src/site.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { render, InMemoryProvider } from "@metaobjectsdev/render"; +import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata"; import { loadModel } from "./load.js"; import { LinkGraph, fqnOf } from "./link-graph.js"; import { CoverageTracker } from "./coverage.js"; @@ -34,6 +35,10 @@ export interface SiteOptions { templatesDir?: string; /** Override dir for assets; if a file of the same basename exists here, it wins over the bundled assets/ dir. */ assetsDir?: string | undefined; + /** Consumer-supplied metamodel providers, composed AFTER the built-in bundle + * (core-types + db + doc + prompt + ui) so a site can document metadata that + * uses custom field/view/object subtypes. Mirrors loadMemory's `providers`. */ + extraProviders?: readonly MetaDataTypeProvider[]; } export interface SiteResult { @@ -131,7 +136,7 @@ function buildNavHtml( export async function generateSite(opts: SiteOptions): Promise { // 1. Load + graph + comments - const loaded = await loadModel(opts.sourceDirs); + const loaded = await loadModel(opts.sourceDirs, opts.extraProviders); const g = new LinkGraph(loaded); const docs = harvestComments(opts.sourceDirs); const cov = new CoverageTracker(); diff --git a/server/typescript/packages/docs-site/test/provider-extension.test.ts b/server/typescript/packages/docs-site/test/provider-extension.test.ts new file mode 100644 index 000000000..32ba5cc4b --- /dev/null +++ b/server/typescript/packages/docs-site/test/provider-extension.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TypeId, TYPE_FIELD, MetaField } from "@metaobjectsdev/metadata"; +import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import { generateSite } from "../src/site"; +import { loadModel } from "../src/load"; + +// A consumer-supplied provider registering a custom field subtype the built-in +// bundle (core-types/db/doc/prompt/ui) does not know — mirrors how an adopter +// ships custom vocabulary via metaobjects.config.ts `providers`. +function geoProvider(): MetaDataTypeProvider { + return { + id: "test-geo", + dependencies: ["metaobjects-core-types"], + registerTypes(registry) { + registry.register({ + typeId: new TypeId(TYPE_FIELD, "geopoint"), + description: "A geographic point field", + factory: (typeId, name) => new MetaField(typeId, name), + childRules: [], + attributes: [], + }); + }, + }; +} + +// Metadata whose `field.geopoint` resolves ONLY when geoProvider is registered. +function customTypeDir(): string { + const root = mkdtempSync(join(tmpdir(), "docs-extra-prov-")); + const acme = join(root, "acme"); + mkdirSync(acme, { recursive: true }); + writeFileSync( + join(acme, "place.yaml"), + [ + "metadata:", + " package: acme::geo", + " children:", + " - object.value:", + " name: Place", + " children:", + " - field.string: { name: name }", + " - field.geopoint: { name: location }", + "", + ].join("\n"), + "utf8", + ); + return acme; +} + +test("loadModel: a custom subtype fails without its provider, resolves with extraProviders", async () => { + const dir = customTypeDir(); + // Without the provider the loader rejects the unknown subtype (the exact + // failure a consumer hit running docs over metadata with custom view/field types). + await expect(loadModel([dir])).rejects.toThrow(/geopoint|not registered|Unknown type/i); + // Threading the provider through extraProviders resolves it. + const model = await loadModel([dir], [geoProvider()]); + expect(model.root.objects().map((o) => o.name)).toContain("Place"); +}); + +test("generateSite: extraProviders lets a site document custom-subtype metadata", async () => { + const dir = customTypeDir(); + const out = mkdtempSync(join(tmpdir(), "docs-extra-out-")); + const r = await generateSite({ + sourceDirs: [dir], + outDir: out, + title: "Fixture", + stamp: "2026-01-01", + commit: "abc1234", + extraProviders: [geoProvider()], + }); + expect(existsSync(join(out, "index.html"))).toBe(true); + expect(r.dangling).toEqual([]); +});