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
33 changes: 31 additions & 2 deletions server/typescript/packages/docs-site/src/load.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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")}`);
}
Expand All @@ -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[] };
Expand Down
69 changes: 69 additions & 0 deletions server/typescript/packages/docs-site/test/overlay-order.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
Loading