@homepages/template-kit 0.2.0 → 0.4.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 (165) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/README.md +96 -17
  3. package/dist/asset-modules.d.ts +81 -0
  4. package/dist/base.css +9 -0
  5. package/dist/cli/check/config.js +41 -0
  6. package/dist/cli/check/css.js +131 -0
  7. package/dist/cli/check/diagnostics.js +32 -0
  8. package/dist/cli/check/index.js +87 -0
  9. package/dist/cli/check/loader.js +146 -0
  10. package/dist/cli/check/paths.js +15 -0
  11. package/dist/cli/check/relativize.js +18 -0
  12. package/dist/cli/check/render-invariants.js +24 -0
  13. package/dist/cli/check/resolve-tool.js +38 -0
  14. package/dist/cli/check/section-assets.js +58 -0
  15. package/dist/cli/check/stages/deps.js +337 -0
  16. package/dist/cli/check/stages/lint.js +46 -0
  17. package/dist/cli/check/stages/render.js +101 -0
  18. package/dist/cli/check/stages/size.js +274 -0
  19. package/dist/cli/check/stages/tree.js +206 -0
  20. package/dist/cli/check/stages/typecheck.js +46 -0
  21. package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
  22. package/dist/cli/check/stages/validate/facts.js +18 -0
  23. package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
  24. package/dist/cli/check/stages/validate/group-contract.js +21 -0
  25. package/dist/cli/check/stages/validate/index.js +66 -0
  26. package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
  27. package/dist/cli/check/stages/validate/nav-contract.js +93 -0
  28. package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
  29. package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
  30. package/dist/cli/check/stages/validate/orchestrator.js +241 -0
  31. package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
  32. package/dist/cli/check/stages/validate/row-contract.js +31 -0
  33. package/dist/cli/check/stages/validate/select-contract.js +13 -0
  34. package/dist/cli/check/stages/validate/source-contract.js +21 -0
  35. package/dist/cli/check/stages/validate/variant-contract.js +15 -0
  36. package/dist/cli/check/workspace.js +86 -0
  37. package/dist/cli/dev/build-css.js +91 -0
  38. package/dist/cli/dev/catalog.js +35 -0
  39. package/dist/cli/dev/compose-template.js +38 -0
  40. package/dist/cli/dev/content-override.js +58 -0
  41. package/dist/cli/dev/discover.js +35 -0
  42. package/dist/cli/dev/fill-state.js +75 -0
  43. package/dist/cli/dev/index.js +32 -0
  44. package/dist/cli/dev/inspect.js +38 -0
  45. package/dist/cli/dev/island-bootstrap.js +38 -0
  46. package/dist/cli/dev/island-map.js +35 -0
  47. package/dist/cli/dev/island-transform.js +31 -0
  48. package/dist/cli/dev/manifest-instances.js +31 -0
  49. package/dist/cli/dev/render-section.js +24 -0
  50. package/dist/cli/dev/screenshot-target.js +24 -0
  51. package/dist/cli/dev/section-page.js +54 -0
  52. package/dist/cli/dev/server.js +480 -0
  53. package/dist/cli/dev/slot-schema.js +12 -0
  54. package/dist/cli/dev/structure-summary.js +118 -0
  55. package/dist/cli/dev/tailwind.js +32 -0
  56. package/dist/cli/dev/vite-server.js +33 -0
  57. package/dist/cli/dev/workspace.js +63 -0
  58. package/dist/cli/link/index.js +74 -0
  59. package/dist/cli/link/overlay.js +59 -0
  60. package/dist/cli/link/watch.js +25 -0
  61. package/dist/cli/new/emit.js +48 -0
  62. package/dist/cli/new/index.js +69 -0
  63. package/dist/cli/new/scaffold/section/_Renderer.tsx +20 -0
  64. package/dist/cli/new/scaffold/section/_fill-spec.ts +18 -0
  65. package/dist/cli/new/scaffold/section/_fixtures.ts +13 -0
  66. package/dist/cli/new/scaffold/section/_schema.ts +24 -0
  67. package/dist/cli/new/scaffold/template/_manifest.json +9 -0
  68. package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
  69. package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +40 -0
  70. package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +14 -0
  71. package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +29 -0
  72. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +41 -0
  73. package/dist/cli/new/scaffold/template/sections/hero/schema.ts +60 -0
  74. package/dist/cli/new/scaffold/template/theme.css +24 -0
  75. package/dist/cli/new/scaffold/template/theme.ts +27 -0
  76. package/dist/cli/new/scaffold-assets.js +20 -0
  77. package/dist/cli/pack/collect.js +44 -0
  78. package/dist/cli/pack/guards.js +57 -0
  79. package/dist/cli/pack/index.js +66 -0
  80. package/dist/cli/pack/manifest.js +15 -0
  81. package/dist/cli/pack/zip.js +86 -0
  82. package/dist/cli/theme/generate.js +43 -0
  83. package/dist/cli/theme/index.js +33 -0
  84. package/dist/cli/theme/load-theme.js +69 -0
  85. package/dist/cli.js +100 -16
  86. package/dist/contracts/fill-treatments.js +47 -0
  87. package/dist/design-system/theme.d.ts +30 -300
  88. package/dist/design-system/theme.js +112 -90
  89. package/dist/dev-client/assets/index-DfEfrp6P.css +1 -0
  90. package/dist/dev-client/assets/index-bpMP0b0W.js +50 -0
  91. package/dist/dev-client/index.html +13 -0
  92. package/dist/eslint/is-client-file.d.ts +4 -0
  93. package/dist/eslint/rules/no-hex.js +78 -6
  94. package/dist/eslint.js +1 -1
  95. package/dist/index.d.ts +3 -2
  96. package/dist/index.js +2 -2
  97. package/dist/islands/detect.d.ts +16 -0
  98. package/dist/islands/detect.js +22 -0
  99. package/dist/islands/discover.d.ts +5 -0
  100. package/dist/islands/discover.js +19 -0
  101. package/dist/islands/esbuild-plugin.d.ts +13 -0
  102. package/dist/islands/esbuild-plugin.js +39 -0
  103. package/dist/islands/wrap.d.ts +11 -0
  104. package/dist/islands/wrap.js +48 -0
  105. package/dist/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.js +77 -0
  106. package/dist/node_modules/magic-string/dist/magic-string.es.js +939 -0
  107. package/dist/package.js +1 -1
  108. package/dist/primitives/Image.js +1 -1
  109. package/dist/schema/fill-spec.d.ts +80 -590
  110. package/dist/schema/fixture-schema.d.ts +5 -81
  111. package/dist/schema/manifest.d.ts +39 -475
  112. package/dist/schema/resolve-section-ref.js +10 -0
  113. package/dist/schema/rows.js +10 -0
  114. package/dist/schema/section-nav.js +9 -0
  115. package/dist/schema/section-schema.d.ts +51 -437
  116. package/dist/schema/slot-types.d.ts +12 -16
  117. package/dist/ssr.d.ts +31 -0
  118. package/dist/ssr.js +46 -0
  119. package/dist/styles.css +31 -88
  120. package/docs/INDEX.md +12 -3
  121. package/docs/assets.md +128 -0
  122. package/docs/check.md +124 -0
  123. package/docs/dev.md +187 -0
  124. package/docs/eslint.md +18 -8
  125. package/docs/islands.md +5 -2
  126. package/docs/llms.txt +59 -9
  127. package/docs/new.md +27 -0
  128. package/docs/overview.md +57 -0
  129. package/docs/pack.md +46 -0
  130. package/docs/primitives.md +10 -6
  131. package/docs/quickstart.md +102 -0
  132. package/docs/recipes/INDEX.md +27 -0
  133. package/docs/recipes/bind-property-fact.md +58 -0
  134. package/docs/recipes/collection-slot.md +100 -0
  135. package/docs/recipes/fill-spec-decision.md +66 -0
  136. package/docs/recipes/fixture-states.md +68 -0
  137. package/docs/recipes/image-slot-crop.md +98 -0
  138. package/docs/recipes/interactive-island.md +100 -0
  139. package/docs/recipes/organize-section-folder.md +72 -0
  140. package/docs/recipes/prepare-submission.md +55 -0
  141. package/docs/recipes/second-template.md +49 -0
  142. package/docs/recipes/select-slot.md +59 -0
  143. package/docs/recipes/static-asset.md +103 -0
  144. package/docs/recipes/third-party-package.md +90 -0
  145. package/docs/rules/INDEX.md +14 -5
  146. package/docs/rules/bundle-binary-asset.md +83 -0
  147. package/docs/rules/bundle-incomplete.md +2 -2
  148. package/docs/rules/css-reason.md +3 -3
  149. package/docs/rules/fixtures-invalid.md +96 -0
  150. package/docs/rules/license-denied.md +11 -3
  151. package/docs/rules/manifest-invalid.md +99 -0
  152. package/docs/rules/no-bare-css-import.md +8 -8
  153. package/docs/rules/no-css-import-from-render-path.md +7 -8
  154. package/docs/rules/no-hex.md +76 -10
  155. package/docs/rules/no-templates.md +87 -0
  156. package/docs/rules/parse-error.md +76 -0
  157. package/docs/rules/schema-invalid.md +96 -0
  158. package/docs/rules/size-assets.md +88 -0
  159. package/docs/rules/size-island-bundle.md +123 -0
  160. package/docs/rules/size-section-css.md +19 -14
  161. package/docs/schema-system.md +28 -28
  162. package/docs/theme-and-css.md +157 -92
  163. package/docs/vocabulary.md +98 -0
  164. package/package.json +14 -11
  165. package/tsconfig.json +1 -1
@@ -0,0 +1,131 @@
1
+ import { loadEsbuild } from "./resolve-tool.js";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { readdir } from "node:fs/promises";
5
+
6
+ //#region src/cli/check/css.ts
7
+ /**
8
+ * Every `.css` file anywhere in a section folder (the folder is free-form —
9
+ * an author may nest CSS under components/, enhancers/, wherever), in
10
+ * published order: `styles.css` first (the conventional root-level home),
11
+ * then every other file sorted by path. `snapshots/` and `node_modules/` are
12
+ * skipped — generated or not authored, never hand-CSS.
13
+ */
14
+ async function sectionCssFiles(dir) {
15
+ if (!existsSync(dir)) return [];
16
+ const found = [];
17
+ const walk = async (d) => {
18
+ for (const entry of await readdir(d, { withFileTypes: true })) {
19
+ const full = join(d, entry.name);
20
+ if (entry.isDirectory()) {
21
+ if (entry.name === "snapshots" || entry.name === "node_modules") continue;
22
+ await walk(full);
23
+ } else if (entry.isFile() && entry.name.endsWith(".css")) found.push(full);
24
+ }
25
+ };
26
+ await walk(dir);
27
+ const own = join(dir, "styles.css");
28
+ const rest = found.filter((f) => f !== own).sort();
29
+ return (existsSync(own) ? [own] : []).concat(rest);
30
+ }
31
+ const ASSET_LOADERS = {
32
+ ".svg": "file",
33
+ ".png": "file",
34
+ ".jpg": "file",
35
+ ".jpeg": "file",
36
+ ".gif": "file",
37
+ ".webp": "file",
38
+ ".avif": "file",
39
+ ".ico": "file",
40
+ ".woff": "file",
41
+ ".woff2": "file",
42
+ ".ttf": "file",
43
+ ".otf": "file",
44
+ ".eot": "file",
45
+ ".mp4": "file",
46
+ ".webm": "file",
47
+ ".mov": "file",
48
+ ".mp3": "file",
49
+ ".wav": "file",
50
+ ".pdf": "file",
51
+ ".zip": "file"
52
+ };
53
+ const ASSET_PUBLIC_PATH = "/section-assets";
54
+ const ASSET_NAMES = "[name]-[hash]";
55
+ /**
56
+ * A section's third-party package CSS — discovered by the bundler, not
57
+ * declared. An author writes `import "swiper/css"` in Renderer.tsx exactly
58
+ * as in React; this runs a CSS-only esbuild pass rooted at Renderer.tsx and
59
+ * returns the emitted stylesheet (empty string if there is none).
60
+ *
61
+ * A relative CSS import in the render path is a lint error
62
+ * (`no-css-import-from-render-path`, already enforced by ESLint) — this pass
63
+ * does not re-implement that rejection, it only has to not crash on one.
64
+ *
65
+ * `platform: "neutral"` defaults `mainFields` to `[]`, which cannot resolve a
66
+ * package that ships only a CJS `main` (no `exports` map) — `mainFields`
67
+ * is set explicitly so such a package still resolves to its ESM `module`
68
+ * build where one exists.
69
+ */
70
+ async function compileSectionPackageCss(workspaceRoot, sectionDir) {
71
+ const renderer = join(sectionDir, "Renderer.tsx");
72
+ if (!existsSync(renderer)) return "";
73
+ const { build } = await loadEsbuild(workspaceRoot);
74
+ const css = (await build({
75
+ entryPoints: [renderer],
76
+ bundle: true,
77
+ write: false,
78
+ minify: true,
79
+ outdir: join(workspaceRoot, "node_modules", ".template-kit", "check-css"),
80
+ format: "esm",
81
+ target: "es2022",
82
+ platform: "neutral",
83
+ mainFields: ["module", "main"],
84
+ jsx: "automatic",
85
+ external: [
86
+ "react",
87
+ "react-dom",
88
+ "react/jsx-runtime"
89
+ ],
90
+ loader: { ...ASSET_LOADERS },
91
+ publicPath: ASSET_PUBLIC_PATH,
92
+ assetNames: ASSET_NAMES,
93
+ absWorkingDir: workspaceRoot,
94
+ logLevel: "silent"
95
+ })).outputFiles?.find((f) => f.path.endsWith(".css"));
96
+ return css ? css.text : "";
97
+ }
98
+ /**
99
+ * The 1-based line of the first top-level `@import`, or `undefined` if there
100
+ * is none. Comment-aware: an `@import` mentioned inside a CSS block comment
101
+ * is prose (e.g. a header warning authors not to write one), not a rule — a
102
+ * naive line-regex would false-fire on it.
103
+ */
104
+ function findImportLine(css) {
105
+ const lines = css.split("\n");
106
+ let inComment = false;
107
+ for (let i = 0; i < lines.length; i++) {
108
+ let line = lines[i];
109
+ if (inComment) {
110
+ const close = line.indexOf("*/");
111
+ if (close === -1) continue;
112
+ line = line.slice(close + 2);
113
+ inComment = false;
114
+ }
115
+ for (;;) {
116
+ const open = line.indexOf("/*");
117
+ if (open === -1) break;
118
+ const close = line.indexOf("*/", open + 2);
119
+ if (close === -1) {
120
+ line = line.slice(0, open);
121
+ inComment = true;
122
+ break;
123
+ }
124
+ line = line.slice(0, open) + line.slice(close + 2);
125
+ }
126
+ if (/^\s*@import\b/.test(line)) return i + 1;
127
+ }
128
+ }
129
+
130
+ //#endregion
131
+ export { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH, compileSectionPackageCss, findImportLine, sectionCssFiles };
@@ -0,0 +1,32 @@
1
+ //#region src/cli/check/diagnostics.ts
2
+ function location(d) {
3
+ if (!d.file) return "";
4
+ return d.line === void 0 ? d.file : `${d.file}:${d.line}`;
5
+ }
6
+ function formatOne(d) {
7
+ const where = location(d);
8
+ return `${where ? ` ${where}\n` : ""} [${d.ruleId}] ${d.message}\n fix: ${d.fix}\n`;
9
+ }
10
+ function formatHuman(report) {
11
+ const parts = [];
12
+ for (const [key, t] of Object.entries(report.templates)) {
13
+ if (t.ok) {
14
+ parts.push(`✓ ${key}\n`);
15
+ continue;
16
+ }
17
+ parts.push(`✗ ${key} — ${String(t.diagnostics.length)} problem(s)\n`);
18
+ for (const d of t.diagnostics) parts.push(formatOne(d));
19
+ }
20
+ if (!report.workspace.ok) {
21
+ parts.push(`✗ workspace — ${String(report.workspace.diagnostics.length)} problem(s)\n`);
22
+ for (const d of report.workspace.diagnostics) parts.push(formatOne(d));
23
+ }
24
+ if (report.ok) parts.push("check passed\n");
25
+ return parts.join("");
26
+ }
27
+ function toJson(report) {
28
+ return JSON.stringify(report, null, 2);
29
+ }
30
+
31
+ //#endregion
32
+ export { formatHuman, toJson };
@@ -0,0 +1,87 @@
1
+ import { KIT_VERSION } from "../../index.js";
2
+ import { formatHuman, toJson } from "./diagnostics.js";
3
+ import { loadEsbuild } from "./resolve-tool.js";
4
+ import { resetLoadCache } from "./loader.js";
5
+ import { depsStage } from "./stages/deps.js";
6
+ import { lintStage } from "./stages/lint.js";
7
+ import { renderStage } from "./stages/render.js";
8
+ import { sizeStage } from "./stages/size.js";
9
+ import { treeStage } from "./stages/tree.js";
10
+ import { typecheckStage } from "./stages/typecheck.js";
11
+ import { validateStage } from "./stages/validate/index.js";
12
+ import { findWorkspaceRoot, selectTemplates, templateFromCwd } from "./workspace.js";
13
+ import { parseArgs } from "node:util";
14
+
15
+ //#region src/cli/check/index.ts
16
+ const STAGES = [
17
+ typecheckStage,
18
+ lintStage,
19
+ validateStage,
20
+ treeStage,
21
+ renderStage,
22
+ sizeStage
23
+ ];
24
+ /**
25
+ * A run that gated nothing must never report success. `every()` over an empty set
26
+ * is vacuously true, so a selection of zero templates printed "check passed" and
27
+ * exited 0 having run not one gate — the worst failure this command has, since
28
+ * ingest re-runs it and would accept the submission on that same silence.
29
+ */
30
+ function emptySelectionDiagnostics(selected, all) {
31
+ if (selected.length > 0) return [];
32
+ return [{
33
+ ruleId: "template-kit/no-templates",
34
+ template: "",
35
+ file: "templates",
36
+ message: all ? "check --all found no template to check: templates/ holds no template folder." : "This workspace has no template to check: templates/ holds no template folder.",
37
+ fix: "Add a template: templates/<key>/ with a manifest.json declaring its section composition and a sections/ folder holding the sections it composes."
38
+ }];
39
+ }
40
+ async function checkTemplate(root, template) {
41
+ const diagnostics = [];
42
+ for (const stage of STAGES) diagnostics.push(...await stage(root, template));
43
+ return {
44
+ ok: diagnostics.length === 0,
45
+ diagnostics
46
+ };
47
+ }
48
+ async function runCheck(argv, cwd) {
49
+ resetLoadCache();
50
+ const { values, positionals } = parseArgs({
51
+ args: argv,
52
+ options: {
53
+ all: {
54
+ type: "boolean",
55
+ default: false
56
+ },
57
+ json: {
58
+ type: "boolean",
59
+ default: false
60
+ }
61
+ },
62
+ allowPositionals: true
63
+ });
64
+ const root = await findWorkspaceRoot(cwd);
65
+ const selected = await selectTemplates(root, positionals[0] ?? (values.all ? void 0 : templateFromCwd(root, cwd)), values.all, "check");
66
+ if (selected.length > 0) await loadEsbuild(root);
67
+ const templates = {};
68
+ for (const template of selected) templates[template.key] = await checkTemplate(root, template);
69
+ const workspaceDiagnostics = [...await depsStage(root), ...emptySelectionDiagnostics(selected, values.all)];
70
+ const workspace = {
71
+ ok: workspaceDiagnostics.length === 0,
72
+ diagnostics: workspaceDiagnostics
73
+ };
74
+ const report = {
75
+ ok: workspace.ok && Object.values(templates).every((t) => t.ok),
76
+ kitVersion: KIT_VERSION,
77
+ templates,
78
+ workspace
79
+ };
80
+ return {
81
+ code: report.ok ? 0 : 1,
82
+ output: values.json ? toJson(report) : formatHuman(report)
83
+ };
84
+ }
85
+
86
+ //#endregion
87
+ export { runCheck };
@@ -0,0 +1,146 @@
1
+ import { loadEsbuild } from "./resolve-tool.js";
2
+ import { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH } from "./css.js";
3
+ import { join, relative } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { mkdir, rm, stat } from "node:fs/promises";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ //#region src/cli/check/loader.ts
9
+ const CONTRACT_FILES = [
10
+ "Renderer.tsx",
11
+ "schema.ts",
12
+ "fill-spec.ts",
13
+ "fixtures.ts"
14
+ ];
15
+ const EXPECTED_EXPORT = {
16
+ "Renderer.tsx": "default",
17
+ "schema.ts": "schema",
18
+ "fill-spec.ts": "fillSpec",
19
+ "fixtures.ts": "default"
20
+ };
21
+ /**
22
+ * A section is missing one or more of the four contract files. Typed (not a bare
23
+ * Error) because exactly one stage owns this failure: the tree stage's
24
+ * `bundle-incomplete`, which names the missing file and where it belongs. Every
25
+ * other stage swallows this cause rather than re-reporting the same root problem
26
+ * under a second rule id.
27
+ */
28
+ var MissingContractFile = class extends Error {
29
+ files;
30
+ constructor(files) {
31
+ super(`Section is missing its contract file(s): ${files.join(", ")}.`);
32
+ this.files = files;
33
+ this.name = "MissingContractFile";
34
+ }
35
+ };
36
+ /** A contract file built, but does not export the binding the contract requires. `file` is workspace-relative. */
37
+ var MissingSectionExport = class extends Error {
38
+ file;
39
+ binding;
40
+ constructor(file, binding) {
41
+ super(binding === "default" ? `${file} has no default export. It must export the section's ${file.endsWith("Renderer.tsx") ? "renderer component" : "fixture module"} as its default export.` : `${file} does not export "${binding}". It must export a "${binding}" binding.`);
42
+ this.file = file;
43
+ this.binding = binding;
44
+ this.name = "MissingSectionExport";
45
+ }
46
+ };
47
+ const RESOLVING = Symbol("css-stub:resolving");
48
+ const cssStub = {
49
+ name: "css-stub",
50
+ setup(b) {
51
+ b.onResolve({ filter: /.*/ }, async (args) => {
52
+ if (args.pluginData === RESOLVING) return null;
53
+ const resolved = await b.resolve(args.path, {
54
+ importer: args.importer,
55
+ namespace: args.namespace,
56
+ resolveDir: args.resolveDir,
57
+ kind: args.kind,
58
+ pluginData: RESOLVING
59
+ });
60
+ if (resolved.errors.length === 0 && resolved.path !== "" && resolved.path.endsWith(".css")) return {
61
+ path: resolved.path,
62
+ namespace: "css-stub"
63
+ };
64
+ if (!args.path.startsWith(".") && !args.path.startsWith("/")) return {
65
+ path: args.path,
66
+ external: true
67
+ };
68
+ return null;
69
+ });
70
+ b.onLoad({
71
+ filter: /.*/,
72
+ namespace: "css-stub"
73
+ }, () => ({
74
+ contents: "",
75
+ loader: "js"
76
+ }));
77
+ }
78
+ };
79
+ const loadCache = /* @__PURE__ */ new Map();
80
+ /** Drops every memoized section load. Called once per `runCheck`; see the note above. */
81
+ function resetLoadCache() {
82
+ loadCache.clear();
83
+ }
84
+ /**
85
+ * Transpile + evaluate a section's four contract modules. Throws `MissingContractFile`
86
+ * when one of the four is absent, `MissingSectionExport` when one built but does not
87
+ * export its required binding, and a plain `Error` (esbuild's, path-relativized by the
88
+ * caller) when one fails to build.
89
+ */
90
+ function loadSection(workspaceRoot, section) {
91
+ const outdir = join(workspaceRoot, "node_modules", ".template-kit", "check", createHash("sha256").update(section.dir).digest("hex").slice(0, 12));
92
+ const cached = loadCache.get(outdir);
93
+ if (cached) return cached;
94
+ const promise = buildAndLoad(section, outdir, workspaceRoot);
95
+ loadCache.set(outdir, promise);
96
+ return promise;
97
+ }
98
+ const isFile = async (p) => {
99
+ try {
100
+ return (await stat(p)).isFile();
101
+ } catch {
102
+ return false;
103
+ }
104
+ };
105
+ async function buildAndLoad(section, outdir, workspaceRoot) {
106
+ const missing = [];
107
+ for (const file of CONTRACT_FILES) if (!await isFile(join(section.dir, file))) missing.push(file);
108
+ if (missing.length > 0) throw new MissingContractFile(missing);
109
+ await rm(outdir, {
110
+ recursive: true,
111
+ force: true
112
+ });
113
+ await mkdir(outdir, { recursive: true });
114
+ const { build } = await loadEsbuild(workspaceRoot);
115
+ await build({
116
+ entryPoints: CONTRACT_FILES.map((f) => join(section.dir, f)),
117
+ outdir,
118
+ bundle: true,
119
+ splitting: true,
120
+ format: "esm",
121
+ platform: "neutral",
122
+ jsx: "automatic",
123
+ logLevel: "silent",
124
+ plugins: [cssStub],
125
+ loader: { ...ASSET_LOADERS },
126
+ publicPath: ASSET_PUBLIC_PATH,
127
+ assetNames: ASSET_NAMES,
128
+ absWorkingDir: workspaceRoot
129
+ });
130
+ const load = async (source, emitted) => {
131
+ const mod = await import(pathToFileURL(join(outdir, emitted)).href);
132
+ const binding = EXPECTED_EXPORT[source];
133
+ const value = mod[binding];
134
+ if (value === void 0) throw new MissingSectionExport(relative(workspaceRoot, join(section.dir, source)), binding);
135
+ return value;
136
+ };
137
+ return {
138
+ Renderer: await load("Renderer.tsx", "Renderer.js"),
139
+ schema: await load("schema.ts", "schema.js"),
140
+ fillSpec: await load("fill-spec.ts", "fill-spec.js"),
141
+ fixtures: await load("fixtures.ts", "fixtures.js")
142
+ };
143
+ }
144
+
145
+ //#endregion
146
+ export { CONTRACT_FILES, MissingContractFile, MissingSectionExport, cssStub, loadSection, resetLoadCache };
@@ -0,0 +1,15 @@
1
+ import { relative, sep } from "node:path";
2
+
3
+ //#region src/cli/check/paths.ts
4
+ /** `file` is `dir` itself or nested under it. */
5
+ function isUnder(dir, file) {
6
+ const rel = relative(dir, file);
7
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`);
8
+ }
9
+ /** The section `file` sits inside, or `undefined` when it isn't under any of the template's sections. */
10
+ function sectionOf(template, file) {
11
+ return template.sections.find((section) => isUnder(section.dir, file))?.name;
12
+ }
13
+
14
+ //#endregion
15
+ export { isUnder, sectionOf };
@@ -0,0 +1,18 @@
1
+ import { resolve, sep } from "node:path";
2
+
3
+ //#region src/cli/check/relativize.ts
4
+ function escapeRegExp(s) {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ }
7
+ /**
8
+ * Rewrites every occurrence of `workspaceRoot` in a tool-sourced message to the
9
+ * path an author would type: workspace-relative, or "." for the root itself.
10
+ * Idempotent, and a no-op on a message that names no path.
11
+ */
12
+ function relativizePaths(message, workspaceRoot) {
13
+ const root = resolve(workspaceRoot);
14
+ return message.replace(new RegExp(`${escapeRegExp(root)}${escapeRegExp(sep)}`, "g"), "").replace(new RegExp(escapeRegExp(root), "g"), ".");
15
+ }
16
+
17
+ //#endregion
18
+ export { relativizePaths };
@@ -0,0 +1,24 @@
1
+ //#region src/cli/check/render-invariants.ts
2
+ function checkRenderInvariants(html) {
3
+ const violations = [];
4
+ if (html.includes("[object Object]")) violations.push({
5
+ rule: "sentinel-text",
6
+ detail: "rendered text contains \"[object Object]\""
7
+ });
8
+ for (const m of html.matchAll(/>\s*(null|undefined|NaN)\s*</g)) violations.push({
9
+ rule: "sentinel-text",
10
+ detail: `rendered leaf is the bare sentinel "${m[1]}"`
11
+ });
12
+ for (const m of html.matchAll(/<img\b[^>]*>/g)) {
13
+ const attr = /\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(m[0]);
14
+ const src = attr ? attr[1] ?? attr[2] : void 0;
15
+ if (src === void 0 || src.trim() === "" || src === "undefined" || src === "null") violations.push({
16
+ rule: "broken-image",
17
+ detail: `<img> with ${src === void 0 ? "no src" : `src="${src}"`}`
18
+ });
19
+ }
20
+ return violations;
21
+ }
22
+
23
+ //#endregion
24
+ export { checkRenderInvariants };
@@ -0,0 +1,38 @@
1
+ import { join } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { createRequire } from "node:module";
4
+
5
+ //#region src/cli/check/resolve-tool.ts
6
+ /**
7
+ * Loads `specifier` exactly as code running in the workspace at `root` would:
8
+ * resolved from that workspace's own `package.json`, never from this
9
+ * package's dependency tree. A missing install is a clean, actionable
10
+ * `Error` — never a bare `MODULE_NOT_FOUND` stack trace.
11
+ */
12
+ async function resolveFromWorkspace(root, specifier) {
13
+ const require = createRequire(pathToFileURL(join(root, "package.json")));
14
+ let resolved;
15
+ try {
16
+ resolved = require.resolve(specifier);
17
+ } catch {
18
+ throw new Error(`${specifier} is not installed in this workspace. Install it: npm i -D ${specifier}`);
19
+ }
20
+ return await import(pathToFileURL(resolved).href);
21
+ }
22
+ /**
23
+ * The workspace's esbuild — `check` transpiles a section's contract modules
24
+ * (loader.ts), bundles its package CSS (css.ts) and measures its published bytes
25
+ * (stages/size.ts) with it.
26
+ *
27
+ * `typeof import("esbuild")` is a TYPE-only import: it is erased at build time, so
28
+ * this module never carries a static `esbuild` import into the shipped `dist/`, and
29
+ * a consumer that only renders sections never installs it. Every call site must
30
+ * `await` this rather than importing `build` at module scope — a static import would
31
+ * put esbuild back on the critical path of `import "@homepages/template-kit"`.
32
+ */
33
+ function loadEsbuild(root) {
34
+ return resolveFromWorkspace(root, "esbuild");
35
+ }
36
+
37
+ //#endregion
38
+ export { loadEsbuild, resolveFromWorkspace };
@@ -0,0 +1,58 @@
1
+ import { loadEsbuild } from "./resolve-tool.js";
2
+ import { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH, sectionCssFiles } from "./css.js";
3
+ import { isUnder } from "./paths.js";
4
+ import { extname, join, resolve } from "node:path";
5
+
6
+ //#region src/cli/check/section-assets.ts
7
+ const ASSET_EXTS = new Set(Object.keys(ASSET_LOADERS));
8
+ async function referencedSectionAssets(workspaceRoot, section) {
9
+ const { build } = await loadEsbuild(workspaceRoot);
10
+ const referenced = /* @__PURE__ */ new Set();
11
+ const collect = (inputs) => {
12
+ for (const key of Object.keys(inputs ?? {})) {
13
+ const abs = resolve(workspaceRoot, key);
14
+ if (ASSET_EXTS.has(extname(abs).toLowerCase()) && isUnder(section.dir, abs)) referenced.add(abs);
15
+ }
16
+ };
17
+ collect((await build({
18
+ entryPoints: [join(section.dir, "Renderer.tsx")],
19
+ bundle: true,
20
+ write: false,
21
+ metafile: true,
22
+ format: "esm",
23
+ platform: "browser",
24
+ jsx: "automatic",
25
+ outdir: "dist",
26
+ external: [
27
+ "react",
28
+ "react-dom",
29
+ "react-dom/server",
30
+ "react/jsx-runtime",
31
+ "react/jsx-dev-runtime"
32
+ ],
33
+ loader: { ...ASSET_LOADERS },
34
+ publicPath: ASSET_PUBLIC_PATH,
35
+ assetNames: ASSET_NAMES,
36
+ absWorkingDir: workspaceRoot,
37
+ logLevel: "silent"
38
+ })).metafile?.inputs);
39
+ for (const cssFile of await sectionCssFiles(section.dir)) collect((await build({
40
+ entryPoints: [cssFile],
41
+ bundle: true,
42
+ write: false,
43
+ metafile: true,
44
+ outdir: "dist",
45
+ loader: {
46
+ ".css": "css",
47
+ ...ASSET_LOADERS
48
+ },
49
+ publicPath: ASSET_PUBLIC_PATH,
50
+ assetNames: ASSET_NAMES,
51
+ absWorkingDir: workspaceRoot,
52
+ logLevel: "silent"
53
+ })).metafile?.inputs);
54
+ return referenced;
55
+ }
56
+
57
+ //#endregion
58
+ export { referencedSectionAssets };