Skip to content

Commit f4dc38a

Browse files
dmealingclaude
andcommitted
feat(codegen-ts): JSON template-spec shape + parser + schema (CLI-port contract)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky
1 parent f87cd21 commit f4dc38a

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://metaobjects.dev/schema/template-spec.json",
4+
"title": "MetaObjects template-codegen spec",
5+
"description": "Declarative Mustache template-generator spec (SP-1). Consumed by the CLI ports (C#/Python) and TS.",
6+
"type": "object",
7+
"required": ["generators"],
8+
"additionalProperties": false,
9+
"properties": {
10+
"generators": {
11+
"type": "array",
12+
"items": {
13+
"type": "object",
14+
"required": ["name", "template", "scope", "outputPattern"],
15+
"additionalProperties": false,
16+
"properties": {
17+
"name": { "type": "string", "minLength": 1 },
18+
"template": { "type": "string", "minLength": 1 },
19+
"scope": { "enum": ["perEntity", "perPackage", "perModel"] },
20+
"outputPattern": { "type": "string", "minLength": 1 },
21+
"format": { "enum": ["text", "html", "xml", "csv", "json", "markdown", "spreadsheet"] },
22+
"target": { "type": "string", "minLength": 1 }
23+
}
24+
}
25+
}
26+
}
27+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// The declarative JSON template-spec the CLI ports (C#/Python) consume, and TS
2+
// can spread into `generators`. The JSON shape is the cross-port contract
3+
// (SP-1 §4); a JSON Schema (template-spec.schema.json) sits beside it.
4+
import type { RenderFormat } from "@metaobjectsdev/render";
5+
import type { Generator } from "../generator.js";
6+
import { templateGenerator, type TemplateScope } from "../generators/template-generator.js";
7+
8+
const SCOPES = ["perEntity", "perPackage", "perModel"] as const satisfies readonly TemplateScope[];
9+
10+
export interface TemplateSpecEntry {
11+
name: string;
12+
template: string;
13+
scope: TemplateScope;
14+
outputPattern: string;
15+
format?: RenderFormat;
16+
target?: string;
17+
}
18+
export interface TemplateSpecFile { generators: TemplateSpecEntry[]; }
19+
20+
function isRecord(v: unknown): v is Record<string, unknown> {
21+
return typeof v === "object" && v !== null && !Array.isArray(v);
22+
}
23+
24+
/** Validate + narrow an untyped JSON value into a TemplateSpecFile. Throws on
25+
* any shape violation (missing/empty required string, bad scope, non-object). */
26+
export function parseTemplateSpec(json: unknown): TemplateSpecFile {
27+
if (!isRecord(json) || !Array.isArray(json.generators)) {
28+
throw new Error("template-spec: expected an object with a `generators` array");
29+
}
30+
const generators = json.generators.map((raw, i): TemplateSpecEntry => {
31+
if (!isRecord(raw)) throw new Error(`template-spec generators[${i}]: expected an object`);
32+
for (const key of ["name", "template", "scope", "outputPattern"] as const) {
33+
if (typeof raw[key] !== "string" || raw[key] === "") {
34+
throw new Error(`template-spec generators[${i}]: missing or empty required string '${key}'`);
35+
}
36+
}
37+
if (!SCOPES.includes(raw.scope as TemplateScope)) {
38+
throw new Error(
39+
`template-spec generators[${i}]: scope must be one of ${SCOPES.join(" | ")}, got '${String(raw.scope)}'`,
40+
);
41+
}
42+
const entry: TemplateSpecEntry = {
43+
name: raw.name as string,
44+
template: raw.template as string,
45+
scope: raw.scope as TemplateScope,
46+
outputPattern: raw.outputPattern as string,
47+
};
48+
if (typeof raw.format === "string") entry.format = raw.format as RenderFormat;
49+
if (typeof raw.target === "string") entry.target = raw.target;
50+
return entry;
51+
});
52+
return { generators };
53+
}
54+
55+
/** Map a parsed spec into runnable Generators (one templateGenerator per entry). */
56+
export function templateSpecToGenerators(spec: TemplateSpecFile): Generator[] {
57+
return spec.generators.map((e) =>
58+
templateGenerator({
59+
name: e.name,
60+
template: e.template,
61+
scope: e.scope,
62+
outputPattern: e.outputPattern,
63+
...(e.format !== undefined ? { format: e.format } : {}),
64+
...(e.target !== undefined ? { target: e.target } : {}),
65+
}),
66+
);
67+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { parseTemplateSpec, templateSpecToGenerators } from "../../src/template-codegen/template-spec.js";
3+
import schema from "../../src/template-codegen/template-spec.schema.json" with { type: "json" };
4+
5+
const VALID = {
6+
generators: [
7+
{ name: "svc", template: "service/entity", scope: "perEntity", outputPattern: "{name}.service.ts" },
8+
{ name: "reg", template: "app/registry", scope: "perModel", outputPattern: "registry.ts", format: "text" },
9+
],
10+
};
11+
12+
describe("parseTemplateSpec", () => {
13+
test("accepts a valid spec", () => {
14+
const spec = parseTemplateSpec(VALID);
15+
expect(spec.generators.length).toBe(2);
16+
expect(spec.generators[0]!.scope).toBe("perEntity");
17+
expect(spec.generators[1]!.format).toBe("text");
18+
});
19+
test("rejects an unknown scope", () => {
20+
expect(() => parseTemplateSpec({ generators: [{ name: "x", template: "t", scope: "perThing", outputPattern: "x" }] }))
21+
.toThrow(/scope/i);
22+
});
23+
test("rejects a missing required field", () => {
24+
expect(() => parseTemplateSpec({ generators: [{ name: "x", template: "t", scope: "perModel" }] }))
25+
.toThrow(/outputPattern/i);
26+
});
27+
test("rejects a non-object", () => {
28+
expect(() => parseTemplateSpec(null)).toThrow();
29+
});
30+
});
31+
32+
describe("templateSpecToGenerators", () => {
33+
test("maps each entry to a Generator with the entry name", () => {
34+
const gens = templateSpecToGenerators(parseTemplateSpec(VALID));
35+
expect(gens.map((g) => g.name)).toEqual(["svc", "reg"]);
36+
});
37+
});
38+
39+
describe("schema ↔ parser drift", () => {
40+
test("schema enumerates the same scopes the parser accepts", () => {
41+
const scopeEnum = (schema as { properties: { generators: { items: { properties: { scope: { enum: string[] } } } } } })
42+
.properties.generators.items.properties.scope.enum;
43+
expect(scopeEnum).toEqual(["perEntity", "perPackage", "perModel"]);
44+
});
45+
});

0 commit comments

Comments
 (0)