@homepages/template-cli 0.1.0

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.
Files changed (110) hide show
  1. package/dist/canvas-core/chrome-css.d.ts +24 -0
  2. package/dist/canvas-core/chrome-css.js +403 -0
  3. package/dist/canvas-core/chrome.d.ts +49 -0
  4. package/dist/canvas-core/chrome.js +147 -0
  5. package/dist/canvas-core/frame.d.ts +20 -0
  6. package/dist/canvas-core/frame.js +23 -0
  7. package/dist/canvas-core/hidden-tabs.d.ts +16 -0
  8. package/dist/canvas-core/hidden-tabs.js +84 -0
  9. package/dist/canvas-core/resolve-target.js +55 -0
  10. package/dist/canvas-core/scroll.d.ts +10 -0
  11. package/dist/canvas-core/scroll.js +15 -0
  12. package/dist/canvas-core/section-dom.d.ts +6 -0
  13. package/dist/canvas-core/section-dom.js +43 -0
  14. package/dist/canvas-core/srcdoc.d.ts +8 -0
  15. package/dist/canvas-core/srcdoc.js +77 -0
  16. package/dist/canvas-core/types.d.ts +76 -0
  17. package/dist/canvas-core.d.ts +9 -0
  18. package/dist/canvas-core.js +9 -0
  19. package/dist/cli/check/config.js +41 -0
  20. package/dist/cli/check/css.js +131 -0
  21. package/dist/cli/check/diagnostics.js +37 -0
  22. package/dist/cli/check/index.js +94 -0
  23. package/dist/cli/check/loader.js +149 -0
  24. package/dist/cli/check/paths.js +15 -0
  25. package/dist/cli/check/relativize.js +18 -0
  26. package/dist/cli/check/render-invariants.js +24 -0
  27. package/dist/cli/check/resolve-tool.js +38 -0
  28. package/dist/cli/check/section-assets.js +58 -0
  29. package/dist/cli/check/stages/deps.js +337 -0
  30. package/dist/cli/check/stages/lint.js +46 -0
  31. package/dist/cli/check/stages/render.js +96 -0
  32. package/dist/cli/check/stages/size.js +274 -0
  33. package/dist/cli/check/stages/tree.js +206 -0
  34. package/dist/cli/check/stages/typecheck.js +46 -0
  35. package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +17 -0
  36. package/dist/cli/check/stages/validate/facts.js +19 -0
  37. package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
  38. package/dist/cli/check/stages/validate/group-contract.js +21 -0
  39. package/dist/cli/check/stages/validate/index.js +66 -0
  40. package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
  41. package/dist/cli/check/stages/validate/nav-contract.js +89 -0
  42. package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
  43. package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
  44. package/dist/cli/check/stages/validate/orchestrator.js +236 -0
  45. package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
  46. package/dist/cli/check/stages/validate/row-contract.js +31 -0
  47. package/dist/cli/check/stages/validate/select-contract.js +13 -0
  48. package/dist/cli/check/stages/validate/source-contract.js +20 -0
  49. package/dist/cli/check/stages/validate/variant-contract.js +15 -0
  50. package/dist/cli/check/workspace.js +99 -0
  51. package/dist/cli/dev/build-css.js +97 -0
  52. package/dist/cli/dev/catalog.js +35 -0
  53. package/dist/cli/dev/compose-template.js +39 -0
  54. package/dist/cli/dev/content-override.js +58 -0
  55. package/dist/cli/dev/diagnostics.js +68 -0
  56. package/dist/cli/dev/discover.js +35 -0
  57. package/dist/cli/dev/fill-state.js +77 -0
  58. package/dist/cli/dev/index.js +42 -0
  59. package/dist/cli/dev/inspect.js +38 -0
  60. package/dist/cli/dev/island-bootstrap.js +38 -0
  61. package/dist/cli/dev/island-canvas-bootstrap.js +204 -0
  62. package/dist/cli/dev/island-map.js +34 -0
  63. package/dist/cli/dev/island-transform.js +31 -0
  64. package/dist/cli/dev/manifest-instances.js +31 -0
  65. package/dist/cli/dev/render-section.js +24 -0
  66. package/dist/cli/dev/screenshot-target.js +24 -0
  67. package/dist/cli/dev/section-page.js +91 -0
  68. package/dist/cli/dev/server.js +510 -0
  69. package/dist/cli/dev/slot-schema.js +12 -0
  70. package/dist/cli/dev/structure-summary.js +119 -0
  71. package/dist/cli/dev/tailwind.js +32 -0
  72. package/dist/cli/dev/tsconfig-paths-plugin.js +90 -0
  73. package/dist/cli/dev/vite-server.js +39 -0
  74. package/dist/cli/dev/workspace.js +63 -0
  75. package/dist/cli/link/index.js +74 -0
  76. package/dist/cli/link/overlay.js +59 -0
  77. package/dist/cli/link/watch.js +25 -0
  78. package/dist/cli/new/emit.js +48 -0
  79. package/dist/cli/new/index.js +69 -0
  80. package/dist/cli/new/scaffold/section/_Renderer.tsx +20 -0
  81. package/dist/cli/new/scaffold/section/_fill-spec.ts +18 -0
  82. package/dist/cli/new/scaffold/section/_fixtures.ts +13 -0
  83. package/dist/cli/new/scaffold/section/_schema.ts +24 -0
  84. package/dist/cli/new/scaffold/template/_manifest.json +9 -0
  85. package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
  86. package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +40 -0
  87. package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +14 -0
  88. package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +29 -0
  89. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +41 -0
  90. package/dist/cli/new/scaffold/template/sections/hero/schema.ts +60 -0
  91. package/dist/cli/new/scaffold/template/theme.css +24 -0
  92. package/dist/cli/new/scaffold/template/theme.ts +27 -0
  93. package/dist/cli/new/scaffold-assets.js +20 -0
  94. package/dist/cli/pack/collect.js +44 -0
  95. package/dist/cli/pack/guards.js +57 -0
  96. package/dist/cli/pack/index.js +66 -0
  97. package/dist/cli/pack/manifest.js +15 -0
  98. package/dist/cli/pack/zip.js +86 -0
  99. package/dist/cli/theme/generate.js +43 -0
  100. package/dist/cli/theme/index.js +33 -0
  101. package/dist/cli/theme/load-theme.js +69 -0
  102. package/dist/cli/version.js +20 -0
  103. package/dist/cli.d.ts +1 -0
  104. package/dist/cli.js +139 -0
  105. package/dist/dev-client/assets/index-CJa9G1Na.css +1 -0
  106. package/dist/dev-client/assets/index-C_1atZs4.js +450 -0
  107. package/dist/dev-client/index.html +13 -0
  108. package/dist/package.js +5 -0
  109. package/dist/tokens.css +21 -0
  110. package/package.json +75 -0
@@ -0,0 +1,22 @@
1
+ //#region src/cli/check/stages/validate/list-scalar-contract.ts
2
+ const SCALAR_ELEMENT_TYPES = /* @__PURE__ */ new Set(["text", "number"]);
3
+ function checkListSlot(slot, label, sectionKey) {
4
+ if (slot.element == null) throw new Error(`Section "${sectionKey}": ${label} must declare \`element\` (a scalar Slot, text|number).`);
5
+ if (!SCALAR_ELEMENT_TYPES.has(slot.element.type ?? "")) throw new Error(`Section "${sectionKey}": ${label} element must be a scalar Slot (text|number), got "${slot.element.type}".`);
6
+ }
7
+ function assertListScalarContract(schema, sectionKey) {
8
+ for (const [slotId, slotUnknown] of Object.entries(schema.slots)) {
9
+ const slot = slotUnknown;
10
+ if (slot.type === "list") {
11
+ checkListSlot(slot, `list "${slotId}"`, sectionKey);
12
+ continue;
13
+ }
14
+ if (slot.type === "object_list" && slot.item != null) for (const [childId, child] of Object.entries(slot.item)) {
15
+ if (child.type !== "list") continue;
16
+ checkListSlot(child, `object_list "${slotId}" field "${childId}"`, sectionKey);
17
+ }
18
+ }
19
+ }
20
+
21
+ //#endregion
22
+ export { assertListScalarContract };
@@ -0,0 +1,89 @@
1
+ import { loadSection } from "../../loader.js";
2
+ import { materializeSection } from "./orchestrator.js";
3
+ import { FixtureModuleShape, TemplateManifest, buildFixtureSet } from "@homepages/template-kit";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { renderSectionHtml, resolveAnchorMap, resolveFixtureSetMedia, resolveSectionRef } from "@homepages/template-kit/ssr";
7
+
8
+ //#region src/cli/check/stages/validate/nav-contract.ts
9
+ const ALLOWED_EXTERNAL_ANCHORS = ["#privacy-policy"];
10
+ function guardedAnchors(anchors, templateKey) {
11
+ return new Proxy(anchors, { get(target, prop, receiver) {
12
+ if (typeof prop === "string" && !(prop in target)) throw new Error(`Template "${templateKey}": section renderer referenced nav.anchors["${prop}"], but no composed section resolves to that key (dangling cross-section link).`);
13
+ return Reflect.get(target, prop, receiver);
14
+ } });
15
+ }
16
+ function collectNavViolations(html, allow) {
17
+ const ids = /* @__PURE__ */ new Set();
18
+ for (const m of html.matchAll(/\sid="([^"]*)"/g)) ids.add(m[1]);
19
+ const allowSet = new Set(allow);
20
+ const violations = /* @__PURE__ */ new Set();
21
+ for (const m of html.matchAll(/\shref="(#[^"]*)"/g)) {
22
+ const href = m[1];
23
+ if (allowSet.has(href)) continue;
24
+ if (!ids.has(href.slice(1))) violations.add(href);
25
+ }
26
+ return [...violations];
27
+ }
28
+ /**
29
+ * Loads the template's manifest.json, resolves every composed section
30
+ * (via resolveSectionRef — bare refs qualify to `<templateKey>/<name>`; the
31
+ * kit does not yet model a `shared/` library, so those never match a local
32
+ * section), builds the ONE composed anchor map keyed by bare section name
33
+ * (the namespace renderers actually read via `nav.anchors[bareName]`),
34
+ * renders each instance's "typical" fixture with that composed nav, and
35
+ * throws naming the template + every dangling in-page link.
36
+ */
37
+ async function assertNavContract(workspaceRoot, template) {
38
+ const raw = JSON.parse(await readFile(join(template.dir, "manifest.json"), "utf8"));
39
+ const manifest = TemplateManifest.parse(raw);
40
+ const sectionByName = new Map(template.sections.map((s) => [s.name, s]));
41
+ const composed = manifest.sections.map((inst) => {
42
+ const resolvedKey = resolveSectionRef(manifest.key, inst.section);
43
+ const bareName = resolvedKey.split("/").pop();
44
+ const target = resolvedKey === `${manifest.key}/${bareName}` ? sectionByName.get(bareName) : void 0;
45
+ if (!target) throw new Error(`Template "${template.key}": section instance "${inst.instance_id}" references unknown section "${inst.section}" (resolved "${resolvedKey}").`);
46
+ return target;
47
+ });
48
+ const materializedByName = /* @__PURE__ */ new Map();
49
+ const rendererByName = /* @__PURE__ */ new Map();
50
+ const fixturesByName = /* @__PURE__ */ new Map();
51
+ for (const section of composed) {
52
+ const loaded = await loadSection(workspaceRoot, section);
53
+ const materialized = materializeSection(section.name, loaded);
54
+ materializedByName.set(section.name, {
55
+ anchor: materialized.schema.anchor,
56
+ schema: materialized.schema
57
+ });
58
+ rendererByName.set(section.name, loaded.Renderer);
59
+ fixturesByName.set(section.name, loaded.fixtures);
60
+ }
61
+ const anchors = guardedAnchors(resolveAnchorMap(composed.map((s) => ({
62
+ key: s.name,
63
+ anchor: materializedByName.get(s.name).anchor
64
+ }))), template.key);
65
+ let html = "";
66
+ for (const section of composed) {
67
+ const { anchor, schema } = materializedByName.get(section.name);
68
+ const base = resolveFixtureSetMedia(buildFixtureSet({
69
+ schema,
70
+ module: FixtureModuleShape.parse(fixturesByName.get(section.name)),
71
+ selfAnchor: anchor
72
+ })).find((f) => f.id === "typical");
73
+ if (!base) throw new Error(`Template "${template.key}": section "${section.name}" has no "typical" base fixture.`);
74
+ html += renderSectionHtml(rendererByName.get(section.name), {
75
+ slots: base.props.slots,
76
+ options: base.props.options,
77
+ nav: {
78
+ selfAnchor: anchor,
79
+ anchors
80
+ },
81
+ variants: base.props.variants
82
+ });
83
+ }
84
+ const violations = collectNavViolations(html, ALLOWED_EXTERNAL_ANCHORS);
85
+ if (violations.length > 0) throw new Error(`Template "${template.key}": nav link(s) resolve to no composed section id: ${violations.join(", ")}.`);
86
+ }
87
+
88
+ //#endregion
89
+ export { assertNavContract, collectNavViolations, guardedAnchors };
@@ -0,0 +1,12 @@
1
+ import { SLOT_TYPES } from "@homepages/template-kit";
2
+
3
+ //#region src/cli/check/stages/validate/nested-slot-contract.ts
4
+ function assertNestedSlotTypes(schema, sectionKey) {
5
+ for (const [slotId, slot] of Object.entries(schema.slots)) {
6
+ if (slot.type !== "object_list" || !slot.item) continue;
7
+ for (const [childId, child] of Object.entries(slot.item)) if (child.type != null && !SLOT_TYPES.includes(child.type)) throw new Error(`Section "${sectionKey}": object_list "${slotId}" item "${childId}" has unknown nested slot type "${child.type}".`);
8
+ }
9
+ }
10
+
11
+ //#endregion
12
+ export { assertNestedSlotTypes };
@@ -0,0 +1,31 @@
1
+ import { COLLECTIONS, collectionForSlot } from "@homepages/template-kit";
2
+
3
+ //#region src/cli/check/stages/validate/object-list-contract.ts
4
+ const SCALAR_SLOT_TYPES = /* @__PURE__ */ new Set([
5
+ "text",
6
+ "number",
7
+ "select"
8
+ ]);
9
+ function assertObjectListContract(schema, sectionKey) {
10
+ for (const [slotId, slotUnknown] of Object.entries(schema.slots)) {
11
+ const slot = slotUnknown;
12
+ if (slot.type !== "object_list") continue;
13
+ const source = slot.source;
14
+ if (source?.kind === "direct") {
15
+ const collection = collectionForSlot({
16
+ type: "object_list",
17
+ source
18
+ });
19
+ if (collection === null) throw new Error(`Section "${sectionKey}": object_list "${slotId}" source.field "${source.field ?? "?"}" is not a known collection (COLLECTIONS in platform/contracts/slot-catalog.ts).`);
20
+ if (!COLLECTIONS[collection].ownership) throw new Error(`Section "${sectionKey}": object_list "${slotId}" collection "${collection}" declares no ownership in COLLECTIONS (platform/contracts/slot-catalog.ts).`);
21
+ }
22
+ const itemSlots = slot.item;
23
+ if (itemSlots == null) throw new Error(`Section "${sectionKey}": object_list "${slotId}" must declare \`item\` (a record of nested slots).`);
24
+ const childIds = Object.keys(itemSlots);
25
+ if (childIds.length === 1 && SCALAR_SLOT_TYPES.has(itemSlots[childIds[0]].type ?? "")) throw new Error(`Section "${sectionKey}": object_list "${slotId}" has a single scalar slot "${childIds[0]}" — use a \`list\` slot instead.`);
26
+ for (const [childId, child] of Object.entries(itemSlots)) if (child.type === "object_list") throw new Error(`Section "${sectionKey}": object_list "${slotId}" item "${childId}" is itself an object_list — object_list is depth-1 (a scalar \`list\` is allowed).`);
27
+ }
28
+ }
29
+
30
+ //#endregion
31
+ export { assertObjectListContract };
@@ -0,0 +1,236 @@
1
+ import { loadSection } from "../../loader.js";
2
+ import { assertCatalogExhaustiveness } from "./catalog-exhaustiveness.js";
3
+ import { FACTS_ARRAY_FIELDS, FACTS_READABLE } from "./facts.js";
4
+ import { assertFormatterContract } from "./formatter-contract.js";
5
+ import { assertGroupContract } from "./group-contract.js";
6
+ import { assertListScalarContract } from "./list-scalar-contract.js";
7
+ import { assertNestedSlotTypes } from "./nested-slot-contract.js";
8
+ import { assertObjectListContract } from "./object-list-contract.js";
9
+ import { assertProducedByContract } from "./produced-by-contract.js";
10
+ import { assertRowContract } from "./row-contract.js";
11
+ import { assertSelectContract } from "./select-contract.js";
12
+ import { assertSourceContract } from "./source-contract.js";
13
+ import { assertVariantContract } from "./variant-contract.js";
14
+ import { isDeepStrictEqual } from "node:util";
15
+ import { FillSpec, FixtureModuleShape, SectionSchema, TemplateManifest } from "@homepages/template-kit";
16
+ import { readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { flattenLayout, parseTreatment, resolveSectionRef } from "@homepages/template-kit/ssr";
19
+
20
+ //#region src/cli/check/stages/validate/orchestrator.ts
21
+ function serializeVariants(variants) {
22
+ const out = {};
23
+ for (const [axis, def] of Object.entries(variants)) out[axis] = {
24
+ cases: def.cases,
25
+ default: def.default
26
+ };
27
+ return out;
28
+ }
29
+ function flattenMeta(authored, sectionName) {
30
+ const { meta, ...rest } = authored;
31
+ return {
32
+ ...rest,
33
+ anchor: meta.anchor ?? sectionName,
34
+ ...meta.variants ? { variants: serializeVariants(meta.variants) } : {},
35
+ ...meta.groups ? { groups: meta.groups } : {}
36
+ };
37
+ }
38
+ /**
39
+ * Zod-parses a loaded section's schema + fill-spec and materializes the
40
+ * bookkeeping fields (slot_order, item_order, flattened meta) every contract
41
+ * and consumer expects. Throws on the first parse/shape failure — there is no
42
+ * schema to check contracts against until this succeeds.
43
+ */
44
+ function materializeSection(key, loaded) {
45
+ const authored = SectionSchema.parse(loaded.schema);
46
+ authored.slot_order = Object.keys(authored.slots);
47
+ for (const slot of Object.values(authored.slots)) if (slot.type === "object_list" && slot.item) slot.item_order = slot.item_layout ? flattenLayout(slot.item_layout) : Object.keys(slot.item);
48
+ return {
49
+ key,
50
+ schema: flattenMeta(authored, key),
51
+ fillSpec: FillSpec.parse(loaded.fillSpec),
52
+ displayName: authored.meta.displayName
53
+ };
54
+ }
55
+ /**
56
+ * Zod-parses the section's fixtures module against the fixture meta-schema,
57
+ * collecting one message per violation. Its own gate (template-kit/fixtures-invalid)
58
+ * because it is a different authored artifact from the schema: the render stage
59
+ * consumes this module, and until it was parsed here an invalid one surfaced only
60
+ * as a raw crash mid-render.
61
+ */
62
+ function validateFixtureModule(key, loaded) {
63
+ const parsed = FixtureModuleShape.safeParse(loaded.fixtures);
64
+ if (parsed.success) return [];
65
+ return parsed.error.issues.map((issue) => {
66
+ const path = issue.path.join(".");
67
+ return `Section "${key}": fixtures.ts — ${path === "" ? "the fixture module" : `"${path}"`}: ${issue.message}.`;
68
+ });
69
+ }
70
+ /** Runs `fn`, pushing its thrown message onto `messages` instead of propagating. */
71
+ function guard(messages, fn) {
72
+ try {
73
+ fn();
74
+ } catch (cause) {
75
+ messages.push(cause instanceof Error ? cause.message : String(cause));
76
+ }
77
+ }
78
+ /**
79
+ * Runs every cross-reference + contract check against an already-loaded
80
+ * section, collecting each violation's message rather than stopping at the
81
+ * first. Returns an empty array when the section is clean.
82
+ */
83
+ function validateOneSection(key, loaded) {
84
+ const messages = [];
85
+ let materialized;
86
+ try {
87
+ materialized = materializeSection(key, loaded);
88
+ } catch (cause) {
89
+ messages.push(cause instanceof Error ? cause.message : String(cause));
90
+ return messages;
91
+ }
92
+ const { schema, fillSpec } = materialized;
93
+ guard(messages, () => {
94
+ assertCatalogExhaustiveness();
95
+ });
96
+ for (const r of fillSpec.reads ?? []) guard(messages, () => {
97
+ const base = r.split(".")[0];
98
+ if (!FACTS_READABLE.has(base)) throw new Error(`Section "${key}": fill-spec reads "${r}" is not an author-declarable facts key (allowed: ${[...FACTS_READABLE].join(", ")}).`);
99
+ });
100
+ for (const d of fillSpec.decisions) {
101
+ for (const produced of d.produces) guard(messages, () => {
102
+ if (!(produced in schema.slots)) throw new Error(`Section "${key}": fill-spec decision "${d.id}" produces unknown slot "${produced}".`);
103
+ });
104
+ if (d.type === "list-extract" || d.type === "structured-list") {
105
+ const decisionType = d.type;
106
+ const decisionId = d.id;
107
+ const iterate = d.iterate;
108
+ const itemCount = d.item_count;
109
+ guard(messages, () => {
110
+ if (typeof iterate === "string" === (itemCount !== void 0)) throw new Error(`Section "${key}": ${decisionType} decision "${decisionId}" must specify exactly one of \`iterate\` or \`item_count\`.`);
111
+ });
112
+ if (typeof iterate === "string") guard(messages, () => {
113
+ if (!FACTS_ARRAY_FIELDS.has(iterate)) throw new Error(`Section "${key}": ${decisionType} decision "${decisionId}" iterate="${iterate}" is not an iterable facts array (allowed: ${[...FACTS_ARRAY_FIELDS].join(", ")}).`);
114
+ });
115
+ if (d.type === "structured-list") {
116
+ const itemFields = d.item_fields;
117
+ for (const [field, raw] of Object.entries(itemFields)) guard(messages, () => {
118
+ if (parseTreatment(raw) === null) throw new Error(`Section "${key}": decision "${decisionId}" item_field "${field}" has invalid treatment "${raw}".`);
119
+ });
120
+ const routeBy = d.route_by;
121
+ const catalog = d.catalog;
122
+ if (typeof routeBy === "string") {
123
+ const slots = new Set((catalog ?? []).map((event) => event.slot).filter((s) => typeof s === "string"));
124
+ for (const p of d.produces) guard(messages, () => {
125
+ if (!slots.has(p)) throw new Error(`Section "${key}": decision "${decisionId}" routes to slot "${p}" but no catalog entry declares slot="${p}".`);
126
+ });
127
+ }
128
+ }
129
+ }
130
+ }
131
+ guard(messages, () => {
132
+ assertProducedByContract(schema, fillSpec, key);
133
+ });
134
+ guard(messages, () => {
135
+ assertVariantContract(schema, key);
136
+ });
137
+ const decisionIds = new Set(fillSpec.decisions.map((d) => d.id));
138
+ guard(messages, () => {
139
+ assertSourceContract({
140
+ key,
141
+ slots: schema.slots
142
+ });
143
+ });
144
+ guard(messages, () => {
145
+ assertFormatterContract({
146
+ key,
147
+ slots: schema.slots
148
+ });
149
+ });
150
+ guard(messages, () => {
151
+ assertObjectListContract(schema, key);
152
+ });
153
+ guard(messages, () => {
154
+ assertNestedSlotTypes(schema, key);
155
+ });
156
+ guard(messages, () => {
157
+ assertListScalarContract(schema, key);
158
+ });
159
+ guard(messages, () => {
160
+ assertSelectContract(schema, key);
161
+ });
162
+ guard(messages, () => {
163
+ assertGroupContract(schema, key);
164
+ });
165
+ guard(messages, () => {
166
+ assertRowContract(schema, key);
167
+ });
168
+ for (const [optName, opt] of Object.entries(schema.options)) {
169
+ for (const dId of opt.affects_decisions) guard(messages, () => {
170
+ if (!decisionIds.has(dId)) throw new Error(`Section "${key}": option "${optName}" affects_decisions contains unknown decision "${dId}".`);
171
+ });
172
+ for (const slotId of opt.affects_slots) guard(messages, () => {
173
+ if (!(slotId in schema.slots)) throw new Error(`Section "${key}": option "${optName}" affects_slots contains unknown slot "${slotId}".`);
174
+ });
175
+ }
176
+ return messages;
177
+ }
178
+ /**
179
+ * Re-materializes every section a template composes (loadSection is
180
+ * memoized, so this is cheap) and resolves + cross-checks the template's
181
+ * manifest.json: the manifest key matches the folder name, every section
182
+ * reference resolves to a real (locally-authored, per resolveSectionRef's D3
183
+ * rule) section, every overridden option exists and is template_overridable,
184
+ * and no override merely restates the section's own default. Finishes with
185
+ * the whole-composition nav contract (nav-contract.ts). Collects every
186
+ * violation rather than stopping at the first.
187
+ */
188
+ async function validateTemplateManifest(workspaceRoot, template) {
189
+ const messages = [];
190
+ let manifest;
191
+ try {
192
+ const raw = JSON.parse(await readFile(join(template.dir, "manifest.json"), "utf8"));
193
+ manifest = TemplateManifest.parse(raw);
194
+ } catch (cause) {
195
+ messages.push(cause instanceof Error ? cause.message : String(cause));
196
+ return messages;
197
+ }
198
+ guard(messages, () => {
199
+ if (manifest.key !== template.key) throw new Error(`Template "${template.key}": manifest key "${manifest.key}" does not match the template folder name "${template.key}".`);
200
+ });
201
+ const sectionByName = /* @__PURE__ */ new Map();
202
+ for (const section of template.sections) try {
203
+ const loaded = await loadSection(workspaceRoot, section);
204
+ sectionByName.set(section.name, materializeSection(section.name, loaded));
205
+ } catch {}
206
+ for (const inst of manifest.sections) {
207
+ const resolvedKey = resolveSectionRef(manifest.key, inst.section);
208
+ const bareName = resolvedKey.split("/").pop();
209
+ const target = resolvedKey === `${manifest.key}/${bareName}` ? sectionByName.get(bareName) : void 0;
210
+ if (!target) {
211
+ guard(messages, () => {
212
+ throw new Error(`Template "${manifest.key}": section instance "${inst.instance_id}" references unknown section "${inst.section}" (resolved "${resolvedKey}").`);
213
+ });
214
+ continue;
215
+ }
216
+ for (const [optName, overrideVal] of Object.entries(inst.options)) guard(messages, () => {
217
+ const sectOpt = target.schema.options[optName];
218
+ if (!sectOpt) throw new Error(`Template "${manifest.key}": section instance "${inst.instance_id}" overrides option "${optName}", which the section "${inst.section}" does not declare.`);
219
+ if (sectOpt.template_overridable === false) throw new Error(`Template "${manifest.key}": section instance "${inst.instance_id}" overrides option "${optName}", but the section declares template_overridable: false.`);
220
+ if (isDeepStrictEqual(overrideVal, sectOpt.default)) throw new Error(`Template "${manifest.key}": section instance "${inst.instance_id}" overrides option "${optName}" with a value equal to the section default — drop it (only genuine overrides belong in the manifest).`);
221
+ });
222
+ }
223
+ const { assertNavContract } = await import("./nav-contract.js");
224
+ await guardAsync(messages, () => assertNavContract(workspaceRoot, template));
225
+ return [...new Set(messages)];
226
+ }
227
+ async function guardAsync(messages, fn) {
228
+ try {
229
+ await fn();
230
+ } catch (cause) {
231
+ messages.push(cause instanceof Error ? cause.message : String(cause));
232
+ }
233
+ }
234
+
235
+ //#endregion
236
+ export { flattenMeta, materializeSection, validateFixtureModule, validateOneSection, validateTemplateManifest };
@@ -0,0 +1,18 @@
1
+ //#region src/cli/check/stages/validate/produced-by-contract.ts
2
+ function assertProducedByContract(schema, fillSpec, sectionKey) {
3
+ const decisionIds = new Set(fillSpec.decisions.map((d) => d.id));
4
+ for (const [slotId, slot] of Object.entries(schema.slots)) {
5
+ const srcKind = slot.source?.kind;
6
+ if (srcKind === "direct" || srcKind === "derived") {
7
+ if (slot.produced_by) throw new Error(`Section "${sectionKey}": slot "${slotId}" is source-bound; remove the redundant produced_by="${slot.produced_by}".`);
8
+ continue;
9
+ }
10
+ if (!slot.produced_by) throw new Error(`Section "${sectionKey}": slot "${slotId}" of type ${slot.type} must declare a \`source\` binding or a \`produced_by\` decision.`);
11
+ if (!decisionIds.has(slot.produced_by)) throw new Error(`Section "${sectionKey}": slot "${slotId}" produced_by="${slot.produced_by}" does not match any decision id.`);
12
+ const producer = fillSpec.decisions.find((d) => d.id === slot.produced_by);
13
+ if (producer && !producer.produces.includes(slotId)) throw new Error(`Section "${sectionKey}": slot "${slotId}" declares produced_by="${slot.produced_by}", but decision "${slot.produced_by}" does not list "${slotId}" in its produces [${producer.produces.join(", ")}].`);
14
+ }
15
+ }
16
+
17
+ //#endregion
18
+ export { assertProducedByContract };
@@ -0,0 +1,31 @@
1
+ import { flattenLayout, isShortSlot } from "@homepages/template-kit/ssr";
2
+
3
+ //#region src/cli/check/stages/validate/row-contract.ts
4
+ function assertRowContract(schema, sectionKey) {
5
+ for (const group of schema.groups ?? []) assertRowPairs(group.members, schema.slots, `group "${group.id}"`, sectionKey);
6
+ for (const [slotId, slot] of Object.entries(schema.slots)) {
7
+ if (slot.type !== "object_list" || !slot.item_layout) continue;
8
+ const itemFields = slot.item ?? {};
9
+ assertRowPairs(slot.item_layout, itemFields, `object_list "${slotId}"`, sectionKey);
10
+ const flat = flattenLayout(slot.item_layout);
11
+ const dup = flat.find((id, i) => flat.indexOf(id) !== i);
12
+ if (dup) throw new Error(`Section "${sectionKey}": object_list "${slotId}" item_layout lists "${dup}" more than once.`);
13
+ for (const id of flat) if (!(id in itemFields)) throw new Error(`Section "${sectionKey}": object_list "${slotId}" item_layout names unknown field "${id}".`);
14
+ for (const key of Object.keys(itemFields)) if (!flat.includes(key)) throw new Error(`Section "${sectionKey}": object_list "${slotId}" item_layout omits field "${key}".`);
15
+ }
16
+ }
17
+ function assertRowPairs(layout, slots, ctx, sectionKey) {
18
+ const order = Object.keys(slots);
19
+ for (const entry of layout) {
20
+ if (typeof entry === "string") continue;
21
+ const [a, b] = entry;
22
+ for (const id of [a, b]) {
23
+ if (!(id in slots)) throw new Error(`Section "${sectionKey}": ${ctx} row names unknown slot "${id}".`);
24
+ if (!isShortSlot(slots[id])) throw new Error(`Section "${sectionKey}": ${ctx} row pairs non-short slot "${id}" (type "${slots[id].type}"); only number/short-text slots may share a row.`);
25
+ }
26
+ if (order.indexOf(b) !== order.indexOf(a) + 1) throw new Error(`Section "${sectionKey}": ${ctx} row [${a}, ${b}] must be adjacent and in order; reorder the declaration so "${b}" immediately follows "${a}".`);
27
+ }
28
+ }
29
+
30
+ //#endregion
31
+ export { assertRowContract };
@@ -0,0 +1,13 @@
1
+ //#region src/cli/check/stages/validate/select-contract.ts
2
+ function assertSelectContract(schema, sectionKey) {
3
+ for (const [slotId, slotUnknown] of Object.entries(schema.slots)) {
4
+ const slot = slotUnknown;
5
+ if (slot.type !== "select") continue;
6
+ const hasValues = slot.values != null;
7
+ const hasOptions = slot.options != null;
8
+ if (!(slot.catalog != null) && hasValues === hasOptions) throw new Error(`Section "${sectionKey}": select "${slotId}" needs exactly one of \`values\` or \`options\` (or a \`catalog\`).`);
9
+ }
10
+ }
11
+
12
+ //#endregion
13
+ export { assertSelectContract };
@@ -0,0 +1,20 @@
1
+ import { SOURCE_FIELDS, TRANSFORMS } from "@homepages/template-kit";
2
+
3
+ //#region src/cli/check/stages/validate/source-contract.ts
4
+ function assertSourceContract(schema) {
5
+ for (const [name, slot] of Object.entries(schema.slots)) {
6
+ const src = slot.source;
7
+ if (!src) continue;
8
+ if (src.kind === "direct") {
9
+ const def = SOURCE_FIELDS[src.field ?? ""];
10
+ if (!def) throw new Error(`${schema.key}.${name}: unknown direct source field "${String(src.field)}"`);
11
+ if (def.image && slot.type !== "image" && slot.type !== "object_list") throw new Error(`${schema.key}.${name}: image source field "${src.field}" must sit on an image slot`);
12
+ continue;
13
+ }
14
+ if (src.kind !== "derived") continue;
15
+ if (!(src.transform !== void 0 && Object.hasOwn(TRANSFORMS, src.transform))) throw new Error(`${schema.key}.${name}: unknown transform "${String(src.transform)}" (add it to platform/contracts/transforms.ts)`);
16
+ }
17
+ }
18
+
19
+ //#endregion
20
+ export { assertSourceContract };
@@ -0,0 +1,15 @@
1
+ //#region src/cli/check/stages/validate/variant-contract.ts
2
+ function assertVariantContract(schema, sectionKey) {
3
+ const axes = schema.variants ?? {};
4
+ for (const [axisName, axis] of Object.entries(axes)) if (!axis.cases.includes(axis.default)) throw new Error(`Section "${sectionKey}": variant axis "${axisName}" default "${axis.default}" is not one of its cases.`);
5
+ for (const [slotId, slot] of Object.entries(schema.slots)) {
6
+ const v = slot.variant;
7
+ if (v == null) continue;
8
+ const axis = axes[v.axis];
9
+ if (!axis) throw new Error(`Section "${sectionKey}": slot "${slotId}" gates on unknown axis "${v.axis}".`);
10
+ for (const c of v.cases) if (!axis.cases.includes(c)) throw new Error(`Section "${sectionKey}": slot "${slotId}" gates on case "${c}" not declared by axis "${v.axis}".`);
11
+ }
12
+ }
13
+
14
+ //#endregion
15
+ export { assertVariantContract };
@@ -0,0 +1,99 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ //#region src/cli/check/workspace.ts
5
+ const isDir = async (p) => {
6
+ try {
7
+ return (await stat(p)).isDirectory();
8
+ } catch {
9
+ return false;
10
+ }
11
+ };
12
+ const isFile = async (p) => {
13
+ try {
14
+ return (await stat(p)).isFile();
15
+ } catch {
16
+ return false;
17
+ }
18
+ };
19
+ /** Nearest ancestor holding both a package.json and a templates/ dir. Throws if there is none. */
20
+ async function findWorkspaceRoot(from) {
21
+ let dir = resolve(from);
22
+ for (;;) {
23
+ if (await isFile(join(dir, "package.json")) && await isDir(join(dir, "templates"))) return dir;
24
+ const parent = dirname(dir);
25
+ if (parent === dir) throw new Error("not inside a workspace — no parent directory has both a package.json and a templates/ folder.\nRun this from your workspace root, or create one with `npm create @homepages/workspace`.");
26
+ dir = parent;
27
+ }
28
+ }
29
+ /**
30
+ * The kit version the workspace DECLARES in package.json — deliberately not what is
31
+ * installed. The link overlay never touches package.json, so declared-vs-installed is
32
+ * precisely the gap the check warning exists to surface.
33
+ */
34
+ async function declaredKitPin(root) {
35
+ try {
36
+ const raw = await readFile(join(root, "package.json"), "utf8");
37
+ return JSON.parse(raw).dependencies?.["@homepages/template-kit"] ?? "unknown";
38
+ } catch {
39
+ return "unknown";
40
+ }
41
+ }
42
+ async function discoverTemplates(root) {
43
+ const templatesDir = join(root, "templates");
44
+ const entries = await readdir(templatesDir, { withFileTypes: true });
45
+ const templates = [];
46
+ for (const entry of entries) {
47
+ if (!entry.isDirectory()) continue;
48
+ const dir = join(templatesDir, entry.name);
49
+ const sectionsDir = join(dir, "sections");
50
+ const sections = [];
51
+ if (await isDir(sectionsDir)) {
52
+ for (const s of await readdir(sectionsDir, { withFileTypes: true })) if (s.isDirectory()) sections.push({
53
+ name: s.name,
54
+ dir: join(sectionsDir, s.name)
55
+ });
56
+ }
57
+ sections.sort((a, b) => a.name.localeCompare(b.name));
58
+ templates.push({
59
+ key: entry.name,
60
+ dir,
61
+ sections
62
+ });
63
+ }
64
+ templates.sort((a, b) => a.key.localeCompare(b.key));
65
+ return templates;
66
+ }
67
+ /**
68
+ * Which templates this invocation gates. A bare invocation inside a template folder
69
+ * infers that template; at the root with several templates it asks for a name.
70
+ * `command` is the invoking subcommand (`check`/`theme`), named back in the
71
+ * "several templates" guidance so it points at the command the author actually ran.
72
+ */
73
+ async function selectTemplates(root, named, all, command) {
74
+ const templates = await discoverTemplates(root);
75
+ const keys = templates.map((t) => t.key);
76
+ if (all) return templates;
77
+ if (named !== void 0) {
78
+ const found = templates.find((t) => t.key === named);
79
+ if (!found) throw new Error(`unknown template "${named}". This workspace has: ${keys.join(", ") || "(none)"}`);
80
+ return [found];
81
+ }
82
+ if (templates.length === 1) return templates;
83
+ if (templates.length === 0) return [];
84
+ throw new Error(`this workspace has several templates (${keys.join(", ")}).\nName one — \`template-kit ${command} <template>\` — or run them all with \`template-kit ${command} --all\`.`);
85
+ }
86
+ /**
87
+ * The key of the template whose folder `cwd` sits inside (`<root>/templates/<key>/…`),
88
+ * or `undefined` when `cwd` is not under any template. Lets a bare `check`/`theme`
89
+ * infer its target from the current directory.
90
+ */
91
+ function templateFromCwd(root, cwd) {
92
+ const prefix = `${root}/templates/`;
93
+ if (!cwd.startsWith(prefix)) return void 0;
94
+ const [key] = cwd.slice(prefix.length).split("/");
95
+ return key === "" ? void 0 : key;
96
+ }
97
+
98
+ //#endregion
99
+ export { declaredKitPin, discoverTemplates, findWorkspaceRoot, selectTemplates, templateFromCwd };