diff --git a/server/typescript/packages/docs-site/src/load.ts b/server/typescript/packages/docs-site/src/load.ts index bd32dc003..a3d40d81e 100644 --- a/server/typescript/packages/docs-site/src/load.ts +++ b/server/typescript/packages/docs-site/src/load.ts @@ -1,8 +1,9 @@ -import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync, statSync, 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, MetaDataTypeProvider } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; export interface LoadedModel { root: MetaRoot; @@ -35,7 +36,17 @@ export async function loadModel( symlinkSync(resolve(dir), join(staging, baseName)); } const registry = composeRegistry([coreTypesProvider, dbProvider, docProvider, promptProvider, uiProvider, ...extraProviders]); - const result = await MetaDataLoader.fromDirectory(staging, { registry, strict: false }); + // Feed files in files-before-subdirs order (the same order the sdk's loadMemory + // uses), NOT fromDirectory's flat basename sort. Cross-file overlays require the + // base (typically a top-level file) to load before an overlay that lives in a + // nested dir; the basename sort can otherwise process e.g. + // `admin-ui/x.admin.yaml` before its base `x.yaml` and fail with + // ERR_OVERLAY_NO_TARGET. (fromDirectory's basename order is a cross-port + // DirectorySource contract, so we order at this boundary rather than change it.) + const files = collectOrderedMetadataFiles(staging); + const result = await new MetaDataLoader({ registry, strict: false }).load( + files.map((f) => new FileSource(f)), + ); if (result.errors.length > 0) { throw new Error(`metadata load failed:\n${result.errors.map((e) => String(e)).join("\n")}`); } @@ -49,6 +60,24 @@ export async function loadModel( } } +/** Metadata files under `dir`, files-before-subdirs with each level sorted — the + * overlay-safe order the sdk's loadMemory uses, so a base loads before an overlay + * nested under it. Symlinks (the staging dir uses them) are followed. */ +function collectOrderedMetadataFiles(dir: string): string[] { + const files: string[] = []; + const subdirs: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const s = statSync(full); // follows symlinks — staging entries are symlinked source dirs + if (s.isDirectory()) subdirs.push(full); + else if (s.isFile() && /\.(json|ya?ml)$/i.test(entry)) files.push(full); + } + files.sort(); + const out = [...files]; + for (const sub of subdirs.sort()) out.push(...collectOrderedMetadataFiles(sub)); + return out; +} + /** Which top-level source dir a node came from (first file path segment of its source envelope). */ export function treeOf(node: MetaData, model: LoadedModel): string { const src = node.source as { files?: string[] }; diff --git a/server/typescript/packages/docs-site/test/overlay-order.test.ts b/server/typescript/packages/docs-site/test/overlay-order.test.ts new file mode 100644 index 000000000..ca42ac0c8 --- /dev/null +++ b/server/typescript/packages/docs-site/test/overlay-order.test.ts @@ -0,0 +1,69 @@ +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 { generateSite } from "../src/site"; +import { loadModel } from "../src/load"; + +// A base object in a TOP-LEVEL file + an `overlay: true` extension in a SUBDIR +// whose basename ("aaa-overlay.yaml") sorts BEFORE the base ("base.yaml"). Under +// fromDirectory's flat basename sort the overlay loads first → ERR_OVERLAY_NO_TARGET; +// the files-before-subdirs order loads the base first so the overlay merges. +function overlayFixture(): string { + const root = mkdtempSync(join(tmpdir(), "docs-overlay-")); + const src = join(root, "acme"); + mkdirSync(join(src, "z-sub"), { recursive: true }); + writeFileSync( + join(src, "base.yaml"), + [ + "metadata:", + " package: acme", + " children:", + " - object.value:", + " name: Widget", + " children:", + " - field.string: { name: id }", + "", + ].join("\n"), + ); + writeFileSync( + join(src, "z-sub", "aaa-overlay.yaml"), + [ + "metadata:", + " package: acme", + " children:", + " - object.value:", + " name: Widget", + " overlay: true", + " children:", + " - field.string: { name: extra }", + "", + ].join("\n"), + ); + return src; +} + +test("cross-file overlay: base (top-level) loads before an overlay nested in a subdir", async () => { + const dir = overlayFixture(); + const model = await loadModel([dir]); + const widget = model.root.objects().find((o) => o.name === "Widget"); + expect(widget).toBeDefined(); + const fieldNames = widget!.childrenOfType("field").map((f) => f.name); + // base field preserved AND the subdir overlay merged its field in + expect(fieldNames).toContain("id"); + expect(fieldNames).toContain("extra"); +}); + +test("generateSite succeeds on a model with a subdir overlay", async () => { + const dir = overlayFixture(); + const out = mkdtempSync(join(tmpdir(), "docs-overlay-out-")); + const r = await generateSite({ + sourceDirs: [dir], + outDir: out, + title: "Fixture", + stamp: "2026-01-01", + commit: "abc1234", + }); + expect(existsSync(join(out, "index.html"))).toBe(true); + expect(r.dangling).toEqual([]); +});