Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions server/typescript/packages/cli/src/commands/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -260,7 +261,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
// 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
Expand Down Expand Up @@ -427,7 +428,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>

// 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;
}

Expand Down Expand Up @@ -496,7 +497,11 @@ async function scaffoldSiteCommand(metaRoot: string): Promise<number> {
* templates/assets into `<metaRoot>/codegen/docs-site/` (via `--scaffold-site`),
* those win over the bundled defaults.
*/
async function emitSite(metaRoot: string, outDir: string): Promise<number> {
async function emitSite(
metaRoot: string,
outDir: string,
configProviders?: readonly MetaDataTypeProvider[],
): Promise<number> {
const siteOutDir = resolvePath(outDir, "site");
const sourceDirs = [join(metaRoot, DEFAULT_METADATA_DIR)];
// Scaffold-and-own: when the consumer has copied templates/assets into
Expand All @@ -511,6 +516,10 @@ async function emitSite(metaRoot: string, outDir: string): Promise<number> {
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 } : {}),
});
Expand Down
13 changes: 13 additions & 0 deletions server/typescript/packages/cli/test/docs-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions server/typescript/packages/docs-site/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
19 changes: 15 additions & 4 deletions server/typescript/packages/docs-site/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@ 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;
warnings: string[];
sourceDirs: string[];
}

/** Load N metadata source dirs into ONE root via a staging dir of symlinks. */
export async function loadModel(sourceDirs: string[]): Promise<LoadedModel> {
/**
* 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<LoadedModel> {
const staging = mkdtempSync(join(tmpdir(), "metadocs-"));
try {
const usedBasenames = new Set<string>();
Expand All @@ -23,7 +34,7 @@ export async function loadModel(sourceDirs: string[]): Promise<LoadedModel> {
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")}`);
Expand Down
7 changes: 6 additions & 1 deletion server/typescript/packages/docs-site/src/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -131,7 +136,7 @@ function buildNavHtml(

export async function generateSite(opts: SiteOptions): Promise<SiteResult> {
// 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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([]);
});
Loading