@metaobjectsdev/codegen-ts 0.12.5 → 0.13.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/dist/generator.d.ts +11 -1
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +32 -1
- package/dist/generator.js.map +1 -1
- package/dist/generators/index.d.ts +4 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +10 -0
- package/dist/generators/index.js.map +1 -1
- package/dist/generators/template-generator.d.ts +14 -2
- package/dist/generators/template-generator.d.ts.map +1 -1
- package/dist/generators/template-generator.js +43 -1
- package/dist/generators/template-generator.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -1
- package/dist/index.js.map +1 -1
- package/dist/reference-templates.d.ts +13 -0
- package/dist/reference-templates.d.ts.map +1 -0
- package/dist/reference-templates.js +44 -0
- package/dist/reference-templates.js.map +1 -0
- package/dist/template-codegen/output-pattern.d.ts +5 -0
- package/dist/template-codegen/output-pattern.d.ts.map +1 -0
- package/dist/template-codegen/output-pattern.js +32 -0
- package/dist/template-codegen/output-pattern.js.map +1 -0
- package/dist/template-codegen/template-data.d.ts +45 -0
- package/dist/template-codegen/template-data.d.ts.map +1 -0
- package/dist/template-codegen/template-data.js +60 -0
- package/dist/template-codegen/template-data.js.map +1 -0
- package/dist/template-codegen/template-spec.d.ts +20 -0
- package/dist/template-codegen/template-spec.d.ts.map +1 -0
- package/dist/template-codegen/template-spec.js +57 -0
- package/dist/template-codegen/template-spec.js.map +1 -0
- package/package.json +6 -6
- package/src/generator.ts +36 -1
- package/src/generators/index.ts +11 -0
- package/src/generators/template-generator.ts +66 -3
- package/src/index.ts +44 -1
- package/src/reference/barrel.ts +66 -0
- package/src/reference/entity.ts +147 -0
- package/src/reference/queries.ts +124 -0
- package/src/reference/routes.ts +58 -0
- package/src/reference-templates.ts +49 -0
- package/src/template-codegen/output-pattern.ts +36 -0
- package/src/template-codegen/template-data.ts +87 -0
- package/src/template-codegen/template-spec.schema.json +27 -0
- package/src/template-codegen/template-spec.ts +75 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/queries.ts) and own it.
|
|
2
|
+
// Then import it LOCALLY in metaobjects.config.ts:
|
|
3
|
+
// import { queriesFile } from "./codegen/generators/queries";
|
|
4
|
+
//
|
|
5
|
+
// use-when: you want generated typed CRUD finders (find<E>ById, list<E>s, create/update/delete)
|
|
6
|
+
// over Drizzle. Drop it if you hand-write your data access.
|
|
7
|
+
// emits: <target>/<Entity>.queries.ts per write-through entity.
|
|
8
|
+
// customize: the vanilla CRUD assembly below is OWNED — reorder, drop verbs (e.g. no delete),
|
|
9
|
+
// change the Db type alias, add your own finders. The render<Verb>Fn primitives emit
|
|
10
|
+
// each block; call your own instead to change a verb's body.
|
|
11
|
+
// composes-with: entity.ts (imports the table + InsertSchema it emits).
|
|
12
|
+
//
|
|
13
|
+
// NOTE: the advanced TPH-base + projection variants delegate to the engine's composer
|
|
14
|
+
// (`renderQueriesFile`) — they're rarely customized. To own those too, copy their branches
|
|
15
|
+
// out of the package source. The vanilla path here is byte-identical to the built-in.
|
|
16
|
+
|
|
17
|
+
import { code, joinCode, type Code } from "ts-poet";
|
|
18
|
+
import { OBJECT_SUBTYPE_VALUE, type MetaObject } from "@metaobjectsdev/metadata";
|
|
19
|
+
import {
|
|
20
|
+
perEntity,
|
|
21
|
+
type Generator,
|
|
22
|
+
type GeneratorFactory,
|
|
23
|
+
type RenderContext,
|
|
24
|
+
entityModuleSpecifier,
|
|
25
|
+
renderFindByIdFn,
|
|
26
|
+
renderListFn,
|
|
27
|
+
renderCreateFn,
|
|
28
|
+
renderUpdateFn,
|
|
29
|
+
renderDeleteByIdFn,
|
|
30
|
+
isTphDiscriminatorBase,
|
|
31
|
+
isProjection,
|
|
32
|
+
isTphSubtype,
|
|
33
|
+
renderQueriesFile, // engine composer — used for the delegated variants
|
|
34
|
+
formatTs,
|
|
35
|
+
entityOutputPath,
|
|
36
|
+
GENERATED_HEADER,
|
|
37
|
+
} from "@metaobjectsdev/codegen-ts";
|
|
38
|
+
|
|
39
|
+
// --- composition (OWNED for the common case) ---
|
|
40
|
+
function renderQueries(obj: MetaObject, ctx: RenderContext): string {
|
|
41
|
+
// Advanced variants delegate to the engine (byte-identical). Own them by copying their source.
|
|
42
|
+
if (isTphDiscriminatorBase(obj, ctx.loadedRoot) || isProjection(obj)) {
|
|
43
|
+
return renderQueriesFile(obj, ctx);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const entityName = obj.name;
|
|
47
|
+
const entityFileName = entityModuleSpecifier(
|
|
48
|
+
ctx.selfTarget,
|
|
49
|
+
ctx.entityModuleTarget,
|
|
50
|
+
obj.package,
|
|
51
|
+
entityName,
|
|
52
|
+
ctx.extStyle,
|
|
53
|
+
);
|
|
54
|
+
const varName = ctx.collectionName(entityName);
|
|
55
|
+
|
|
56
|
+
// `db` is parameter-passed into every finder (ADR-0008). Emit the dialect-correct
|
|
57
|
+
// Drizzle type alias so signatures typecheck without the consumer constructing one.
|
|
58
|
+
const dbTypeImport =
|
|
59
|
+
ctx.dialect === "postgres"
|
|
60
|
+
? `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`
|
|
61
|
+
: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
|
|
62
|
+
const dbTypeAlias =
|
|
63
|
+
ctx.dialect === "postgres"
|
|
64
|
+
? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
|
|
65
|
+
: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
|
|
66
|
+
|
|
67
|
+
const literalImports = code`
|
|
68
|
+
${dbTypeImport}
|
|
69
|
+
${dbTypeAlias}
|
|
70
|
+
|
|
71
|
+
import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON.stringify(entityFileName)};
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const sections: Code[] = [
|
|
75
|
+
literalImports,
|
|
76
|
+
renderFindByIdFn(obj, ctx),
|
|
77
|
+
renderListFn(obj, ctx),
|
|
78
|
+
renderCreateFn(obj, ctx),
|
|
79
|
+
renderUpdateFn(obj, ctx),
|
|
80
|
+
renderDeleteByIdFn(obj, ctx),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const body = joinCode(sections, { on: "\n" }).toString();
|
|
84
|
+
const header =
|
|
85
|
+
`// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
|
|
86
|
+
`// Source metadata: ${entityName} (${obj.fqn()})\n` +
|
|
87
|
+
`// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
|
|
88
|
+
return header + body;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface QueriesFileOpts {
|
|
92
|
+
filter?: (entity: MetaObject) => boolean;
|
|
93
|
+
target?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// value objects have no identity (findById/updateById would target a non-existent column),
|
|
97
|
+
// and TPH subtypes emit no standalone queries file — both are skipped unconditionally.
|
|
98
|
+
const skipNonQueryable = (e: MetaObject): boolean =>
|
|
99
|
+
e.subType !== OBJECT_SUBTYPE_VALUE && !isTphSubtype(e);
|
|
100
|
+
|
|
101
|
+
export const queriesFile = function queriesFile(opts?: QueriesFileOpts): Generator {
|
|
102
|
+
const userFilter = opts?.filter;
|
|
103
|
+
const filter: (e: MetaObject) => boolean = userFilter
|
|
104
|
+
? (e) => skipNonQueryable(e) && userFilter(e)
|
|
105
|
+
: skipNonQueryable;
|
|
106
|
+
|
|
107
|
+
const generator: Generator = {
|
|
108
|
+
name: "queries-file",
|
|
109
|
+
filter,
|
|
110
|
+
generate: perEntity(async (entity, ctx) => {
|
|
111
|
+
if (!ctx.renderContext) {
|
|
112
|
+
throw new Error("queries-file: renderContext is required (provided by runGen)");
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.queries.ts`),
|
|
116
|
+
content: await formatTs(renderQueries(entity, ctx.renderContext)),
|
|
117
|
+
};
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
if (opts?.target) {
|
|
121
|
+
generator.target = opts.target;
|
|
122
|
+
}
|
|
123
|
+
return generator;
|
|
124
|
+
} as GeneratorFactory<QueriesFileOpts>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/routes.ts) and own it.
|
|
2
|
+
// Then import it LOCALLY in metaobjects.config.ts:
|
|
3
|
+
// import { routesFile } from "./codegen/generators/routes";
|
|
4
|
+
//
|
|
5
|
+
// use-when: you want generated Fastify REST routes per entity. Drop it and hand-write routes
|
|
6
|
+
// if you need bespoke endpoints — or keep it and add handlers via <Entity>.extra.ts.
|
|
7
|
+
// emits: <target>/<Entity>.routes.ts — full CRUD for write-through entities, read-only
|
|
8
|
+
// (GET list + GET :id) for projections, polymorphic + per-subtype for TPH bases.
|
|
9
|
+
// customize: this generator (filter, output path, per-entity @emitRoutes opt-out, target) is
|
|
10
|
+
// YOURS — edit it freely. The route *composition* itself is richer than the others
|
|
11
|
+
// (M:N junction traversal, TPH per-subtype route sets), so it stays in the engine via
|
|
12
|
+
// `renderRoutesFile`. To own the composition too, copy `renderRoutesFile`'s body out
|
|
13
|
+
// of the package source — it dispatches projection → mountReadOnlyCrudRoutes,
|
|
14
|
+
// write-through → mountCrudRoutes (+ M:N mounts). For per-verb control, import the
|
|
15
|
+
// mount* helpers from `@metaobjectsdev/runtime-ts/drizzle-fastify` and mix with your
|
|
16
|
+
// own handlers (auth, side effects).
|
|
17
|
+
// composes-with: entity.ts (imports the table/schemas/allowlists), queries.ts.
|
|
18
|
+
|
|
19
|
+
import { type MetaObject } from "@metaobjectsdev/metadata";
|
|
20
|
+
import {
|
|
21
|
+
perEntity,
|
|
22
|
+
type Generator,
|
|
23
|
+
type GeneratorFactory,
|
|
24
|
+
renderRoutesFile,
|
|
25
|
+
isTphSubtype,
|
|
26
|
+
formatTs,
|
|
27
|
+
entityOutputPath,
|
|
28
|
+
CODEGEN_ATTR_EMIT_ROUTES,
|
|
29
|
+
} from "@metaobjectsdev/codegen-ts";
|
|
30
|
+
|
|
31
|
+
export interface RoutesFileOpts {
|
|
32
|
+
filter?: (entity: MetaObject) => boolean;
|
|
33
|
+
target?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const routesFile = function routesFile(opts?: RoutesFileOpts): Generator {
|
|
37
|
+
const userFilter = opts?.filter ?? (() => true);
|
|
38
|
+
const generator: Generator = {
|
|
39
|
+
name: "routes-file",
|
|
40
|
+
// per-entity opt-out via `@emitRoutes: false`; TPH subtypes get no standalone routes
|
|
41
|
+
// file (their routes live in the discriminator base's); AND-composed with your filter.
|
|
42
|
+
filter: (e: MetaObject) =>
|
|
43
|
+
e.ownAttr(CODEGEN_ATTR_EMIT_ROUTES) !== false && !isTphSubtype(e) && userFilter(e),
|
|
44
|
+
generate: perEntity(async (entity, ctx) => {
|
|
45
|
+
if (!ctx.renderContext) {
|
|
46
|
+
throw new Error("routes-file: renderContext is required (provided by runGen)");
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.routes.ts`),
|
|
50
|
+
content: await formatTs(renderRoutesFile(entity, ctx.renderContext)),
|
|
51
|
+
};
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
if (opts?.target) {
|
|
55
|
+
generator.target = opts.target;
|
|
56
|
+
}
|
|
57
|
+
return generator;
|
|
58
|
+
} as GeneratorFactory<RoutesFileOpts>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ADR-0034 scaffold-and-own — locate + read the copyable reference generators that
|
|
2
|
+
// live (as raw source assets) in `src/reference/*.ts`. `meta init` reads them through
|
|
3
|
+
// here and writes them into the consumer's repo (e.g. `codegen/generators/*.ts`), which
|
|
4
|
+
// the consumer then OWNS. The templates import only `@metaobjectsdev/codegen-ts` (the
|
|
5
|
+
// stable engine), so a copied file works verbatim with no rewriting.
|
|
6
|
+
//
|
|
7
|
+
// The reference files are excluded from the tsc build (they are scaffold assets, not
|
|
8
|
+
// package source — see tsconfig.json). They ship to npm via the package `files: ["src"]`
|
|
9
|
+
// entry, so they are present at `<pkg>/src/reference/*.ts` in a published install.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
/** Basenames (no extension) of the copyable reference generators shipped in `src/reference/`. */
|
|
16
|
+
export const REFERENCE_GENERATOR_NAMES = ["entity", "queries", "routes", "barrel"] as const;
|
|
17
|
+
export type ReferenceGeneratorName = (typeof REFERENCE_GENERATOR_NAMES)[number];
|
|
18
|
+
|
|
19
|
+
/** A directory is the reference root iff it holds the entity reference template. */
|
|
20
|
+
function isReferenceRoot(dir: string): boolean {
|
|
21
|
+
return existsSync(join(dir, "entity.ts"));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the `src/reference/` directory holding the copyable reference generators.
|
|
26
|
+
* Works in dev (this module runs from `src/`, templates at `./reference/`) and in a
|
|
27
|
+
* published install (this module runs from `dist/`, templates at `../src/reference/`,
|
|
28
|
+
* since `src/` ships alongside `dist/`). Walks up checking both layouts at each level.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveReferenceRoot(): string {
|
|
31
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
for (let i = 0; i < 8; i++) {
|
|
33
|
+
for (const candidate of [join(dir, "reference"), join(dir, "src", "reference")]) {
|
|
34
|
+
if (isReferenceRoot(candidate)) return candidate;
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(dir);
|
|
37
|
+
if (parent === dir) break;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(
|
|
41
|
+
"codegen-ts reference templates not found — looked for `reference/` and `src/reference/` " +
|
|
42
|
+
"walking up from the codegen-ts module.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read the raw source of one reference generator (e.g. `"entity"` → the text of `entity.ts`). */
|
|
47
|
+
export function readReferenceTemplate(name: ReferenceGeneratorName): string {
|
|
48
|
+
return readFileSync(join(resolveReferenceRoot(), `${name}.ts`), "utf8");
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Expands the tiny, fixed output-pattern grammar shared cross-port (SP-1 §3.3).
|
|
2
|
+
// Placeholders: {name}, {Name} (PascalCase of name), {package} (:: → /).
|
|
3
|
+
// An empty {package} collapses its trailing slash so `{package}/{name}` with no
|
|
4
|
+
// package yields just `{name}`. Unknown placeholders are a hard error.
|
|
5
|
+
|
|
6
|
+
const KNOWN = new Set(["name", "Name", "package"]);
|
|
7
|
+
|
|
8
|
+
function pascalCase(s: string): string {
|
|
9
|
+
return s
|
|
10
|
+
.split(/[^A-Za-z0-9]+/)
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
13
|
+
.join("");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function expandOutputPattern(
|
|
17
|
+
pattern: string,
|
|
18
|
+
vars: { name?: string; package?: string },
|
|
19
|
+
): string {
|
|
20
|
+
let pkgWasEmpty = false;
|
|
21
|
+
const out = pattern.replace(/\{(\w+)\}/g, (_m, token: string) => {
|
|
22
|
+
if (!KNOWN.has(token)) {
|
|
23
|
+
throw new Error(`unknown placeholder {${token}} in output pattern '${pattern}'`);
|
|
24
|
+
}
|
|
25
|
+
if (token === "package") {
|
|
26
|
+
const p = (vars.package ?? "").replaceAll("::", "/");
|
|
27
|
+
if (p === "") pkgWasEmpty = true;
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
if (vars.name === undefined) {
|
|
31
|
+
throw new Error(`output pattern '${pattern}' uses {${token}} but no entity name is in scope`);
|
|
32
|
+
}
|
|
33
|
+
return token === "Name" ? pascalCase(vars.name) : vars.name;
|
|
34
|
+
});
|
|
35
|
+
return pkgWasEmpty ? out.replace(/^\/+/, "").replace(/\/{2,}/g, "/") : out;
|
|
36
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The NEUTRAL, structural codegen template data dict (SP-1 §3.2). Distinct from
|
|
2
|
+
// the Markdown-flavored EntityDocData — this carries raw structural facts only,
|
|
3
|
+
// so a consumer's Mustache template can emit any language's code from it. The
|
|
4
|
+
// field names here are a byte-gated cross-port contract; change them only via the
|
|
5
|
+
// spec (and the conformance corpus).
|
|
6
|
+
import type { MetaObject, MetaRoot, MetaField } from "@metaobjectsdev/metadata";
|
|
7
|
+
import { FIELD_ATTR_VALUES, FIELD_SUBTYPE_ENUM } from "@metaobjectsdev/metadata";
|
|
8
|
+
import { effectivePackage } from "../docs-paths.js";
|
|
9
|
+
|
|
10
|
+
/** The effective package of an object — its own package OR the file-default
|
|
11
|
+
* folded into `resolutionKey()`. `entity.package` alone is usually undefined
|
|
12
|
+
* (object fqn() stays bare), so all package reads go through here. */
|
|
13
|
+
export function packageOf(entity: MetaObject): string {
|
|
14
|
+
return effectivePackage(entity) ?? "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FieldTemplateData {
|
|
18
|
+
name: string;
|
|
19
|
+
/** Neutral field subtype, e.g. "string" | "int" | "currency" | "enum".
|
|
20
|
+
* Arrayness is carried by `isArray`, NOT appended here. */
|
|
21
|
+
type: string;
|
|
22
|
+
required: boolean;
|
|
23
|
+
isArray: boolean;
|
|
24
|
+
maxLength?: number;
|
|
25
|
+
enumValues?: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface IdentityTemplateData { kind: string; fields: string[]; }
|
|
28
|
+
export interface RelationshipTemplateData { name: string; cardinality: string; targetRef: string; }
|
|
29
|
+
export interface EntityTemplateData {
|
|
30
|
+
name: string;
|
|
31
|
+
package: string;
|
|
32
|
+
fields: FieldTemplateData[];
|
|
33
|
+
identities: IdentityTemplateData[];
|
|
34
|
+
relationships: RelationshipTemplateData[];
|
|
35
|
+
}
|
|
36
|
+
export interface PackageTemplateData { package: string; entities: EntityTemplateData[]; }
|
|
37
|
+
export interface ModelTemplateData { packages: PackageTemplateData[]; }
|
|
38
|
+
|
|
39
|
+
function fieldData(field: MetaField): FieldTemplateData {
|
|
40
|
+
const d: FieldTemplateData = {
|
|
41
|
+
name: field.name,
|
|
42
|
+
type: field.subType,
|
|
43
|
+
required: field.isRequired,
|
|
44
|
+
isArray: field.isArray === true,
|
|
45
|
+
};
|
|
46
|
+
if (typeof field.maxLength === "number") d.maxLength = field.maxLength;
|
|
47
|
+
if (field.subType === FIELD_SUBTYPE_ENUM) {
|
|
48
|
+
const vals = field.attr(FIELD_ATTR_VALUES);
|
|
49
|
+
if (Array.isArray(vals)) d.enumValues = vals.map((v) => String(v));
|
|
50
|
+
}
|
|
51
|
+
return d;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildEntityTemplateData(entity: MetaObject): EntityTemplateData {
|
|
55
|
+
return {
|
|
56
|
+
name: entity.name,
|
|
57
|
+
package: packageOf(entity),
|
|
58
|
+
fields: entity.fields().map(fieldData),
|
|
59
|
+
identities: entity.identities().map((i) => ({ kind: i.subType, fields: [...i.fields] })),
|
|
60
|
+
relationships: entity.relationships().map((r) => ({
|
|
61
|
+
name: r.name,
|
|
62
|
+
cardinality: r.cardinality ?? "",
|
|
63
|
+
targetRef: r.objectRef ?? "",
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildPackageTemplateData(pkg: string, entities: MetaObject[]): PackageTemplateData {
|
|
69
|
+
return { package: pkg, entities: entities.map(buildEntityTemplateData) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Groups concrete (non-abstract) objects by package — packages ascending,
|
|
73
|
+
* entities in `root.objects()` order. Abstract objects never emit instance
|
|
74
|
+
* artifacts, so they are excluded. */
|
|
75
|
+
export function buildModelTemplateData(root: MetaRoot): ModelTemplateData {
|
|
76
|
+
const concrete = root.objects().filter((o) => o.isAbstract !== true);
|
|
77
|
+
const byPkg = new Map<string, MetaObject[]>();
|
|
78
|
+
for (const o of concrete) {
|
|
79
|
+
const pkg = packageOf(o);
|
|
80
|
+
let bucket = byPkg.get(pkg);
|
|
81
|
+
if (bucket === undefined) { bucket = []; byPkg.set(pkg, bucket); }
|
|
82
|
+
bucket.push(o);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
packages: [...byPkg.keys()].sort().map((pkg) => buildPackageTemplateData(pkg, byPkg.get(pkg)!)),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { ESCAPERS, 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
|
+
const FORMATS = Object.keys(ESCAPERS) as readonly RenderFormat[];
|
|
10
|
+
|
|
11
|
+
export interface TemplateSpecEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
template: string;
|
|
14
|
+
scope: TemplateScope;
|
|
15
|
+
outputPattern: string;
|
|
16
|
+
format?: RenderFormat;
|
|
17
|
+
target?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface TemplateSpecFile { generators: TemplateSpecEntry[]; }
|
|
20
|
+
|
|
21
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
22
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Validate + narrow an untyped JSON value into a TemplateSpecFile. Throws on
|
|
26
|
+
* any shape violation (missing/empty required string, bad scope, non-object). */
|
|
27
|
+
export function parseTemplateSpec(json: unknown): TemplateSpecFile {
|
|
28
|
+
if (!isRecord(json) || !Array.isArray(json.generators)) {
|
|
29
|
+
throw new Error("template-spec: expected an object with a `generators` array");
|
|
30
|
+
}
|
|
31
|
+
const generators = json.generators.map((raw, i): TemplateSpecEntry => {
|
|
32
|
+
if (!isRecord(raw)) throw new Error(`template-spec generators[${i}]: expected an object`);
|
|
33
|
+
for (const key of ["name", "template", "scope", "outputPattern"] as const) {
|
|
34
|
+
if (typeof raw[key] !== "string" || raw[key] === "") {
|
|
35
|
+
throw new Error(`template-spec generators[${i}]: missing or empty required string '${key}'`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!SCOPES.includes(raw.scope as TemplateScope)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`template-spec generators[${i}]: scope must be one of ${SCOPES.join(" | ")}, got '${String(raw.scope)}'`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const entry: TemplateSpecEntry = {
|
|
44
|
+
name: raw.name as string,
|
|
45
|
+
template: raw.template as string,
|
|
46
|
+
scope: raw.scope as TemplateScope,
|
|
47
|
+
outputPattern: raw.outputPattern as string,
|
|
48
|
+
};
|
|
49
|
+
if (raw.format !== undefined) {
|
|
50
|
+
if (typeof raw.format !== "string" || !FORMATS.includes(raw.format as RenderFormat)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`template-spec generators[${i}]: format must be one of ${FORMATS.join(" | ")}, got '${String(raw.format)}'`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
entry.format = raw.format as RenderFormat;
|
|
56
|
+
}
|
|
57
|
+
if (typeof raw.target === "string") entry.target = raw.target;
|
|
58
|
+
return entry;
|
|
59
|
+
});
|
|
60
|
+
return { generators };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Map a parsed spec into runnable Generators (one templateGenerator per entry). */
|
|
64
|
+
export function templateSpecToGenerators(spec: TemplateSpecFile): Generator[] {
|
|
65
|
+
return spec.generators.map((e) =>
|
|
66
|
+
templateGenerator({
|
|
67
|
+
name: e.name,
|
|
68
|
+
template: e.template,
|
|
69
|
+
scope: e.scope,
|
|
70
|
+
outputPattern: e.outputPattern,
|
|
71
|
+
...(e.format !== undefined ? { format: e.format } : {}),
|
|
72
|
+
...(e.target !== undefined ? { target: e.target } : {}),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|