@homepages/template-kit 0.3.0 → 0.4.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +39 -4
  3. package/dist/asset-modules.d.ts +81 -0
  4. package/dist/cli/check/config.js +5 -1
  5. package/dist/cli/check/css.js +27 -13
  6. package/dist/cli/check/loader.js +4 -0
  7. package/dist/cli/check/section-assets.js +58 -0
  8. package/dist/cli/check/stages/size.js +124 -8
  9. package/dist/cli/check/stages/tree.js +26 -27
  10. package/dist/cli/dev/build-css.js +91 -0
  11. package/dist/cli/dev/catalog.js +35 -0
  12. package/dist/cli/dev/compose-template.js +38 -0
  13. package/dist/cli/dev/content-override.js +58 -0
  14. package/dist/cli/dev/discover.js +35 -0
  15. package/dist/cli/dev/fill-state.js +75 -0
  16. package/dist/cli/dev/index.js +32 -0
  17. package/dist/cli/dev/inspect.js +38 -0
  18. package/dist/cli/dev/island-bootstrap.js +38 -0
  19. package/dist/cli/dev/island-map.js +35 -0
  20. package/dist/cli/dev/island-transform.js +31 -0
  21. package/dist/cli/dev/manifest-instances.js +31 -0
  22. package/dist/cli/dev/render-section.js +24 -0
  23. package/dist/cli/dev/screenshot-target.js +24 -0
  24. package/dist/cli/dev/section-page.js +54 -0
  25. package/dist/cli/dev/server.js +480 -0
  26. package/dist/cli/dev/slot-schema.js +12 -0
  27. package/dist/cli/dev/structure-summary.js +118 -0
  28. package/dist/cli/dev/tailwind.js +32 -0
  29. package/dist/cli/dev/vite-server.js +33 -0
  30. package/dist/cli/dev/workspace.js +63 -0
  31. package/dist/cli/link/index.js +74 -0
  32. package/dist/cli/link/overlay.js +59 -0
  33. package/dist/cli/link/watch.js +25 -0
  34. package/dist/cli/new/emit.js +48 -0
  35. package/dist/cli/new/index.js +69 -0
  36. package/dist/cli/new/scaffold/section/_Renderer.tsx +20 -0
  37. package/dist/cli/new/scaffold/section/_fill-spec.ts +18 -0
  38. package/dist/cli/new/scaffold/section/_fixtures.ts +13 -0
  39. package/dist/cli/new/scaffold/section/_schema.ts +24 -0
  40. package/dist/cli/new/scaffold/template/_manifest.json +9 -0
  41. package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
  42. package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +40 -0
  43. package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +14 -0
  44. package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +29 -0
  45. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +41 -0
  46. package/dist/cli/new/scaffold/template/sections/hero/schema.ts +60 -0
  47. package/dist/cli/new/scaffold/template/theme.css +24 -0
  48. package/dist/cli/new/scaffold/template/theme.ts +27 -0
  49. package/dist/cli/new/scaffold-assets.js +20 -0
  50. package/dist/cli/pack/collect.js +44 -0
  51. package/dist/cli/pack/guards.js +57 -0
  52. package/dist/cli/pack/index.js +66 -0
  53. package/dist/cli/pack/manifest.js +15 -0
  54. package/dist/cli/pack/zip.js +86 -0
  55. package/dist/cli.js +80 -14
  56. package/dist/dev-client/assets/index-DfEfrp6P.css +1 -0
  57. package/dist/dev-client/assets/index-bpMP0b0W.js +50 -0
  58. package/dist/dev-client/index.html +13 -0
  59. package/dist/eslint/is-client-file.d.ts +4 -0
  60. package/dist/index.d.ts +1 -0
  61. package/dist/islands/detect.d.ts +16 -0
  62. package/dist/islands/detect.js +22 -0
  63. package/dist/islands/discover.d.ts +5 -0
  64. package/dist/islands/discover.js +19 -0
  65. package/dist/islands/esbuild-plugin.d.ts +13 -0
  66. package/dist/islands/esbuild-plugin.js +39 -0
  67. package/dist/islands/wrap.d.ts +12 -0
  68. package/dist/islands/wrap.js +48 -0
  69. package/dist/package.js +1 -1
  70. package/dist/ssr.d.ts +31 -0
  71. package/dist/ssr.js +46 -0
  72. package/docs/INDEX.md +9 -1
  73. package/docs/assets.md +128 -0
  74. package/docs/check.md +6 -3
  75. package/docs/dev.md +187 -0
  76. package/docs/islands.md +5 -2
  77. package/docs/llms.txt +41 -3
  78. package/docs/new.md +27 -0
  79. package/docs/overview.md +57 -0
  80. package/docs/pack.md +46 -0
  81. package/docs/primitives.md +5 -6
  82. package/docs/quickstart.md +102 -0
  83. package/docs/recipes/INDEX.md +27 -0
  84. package/docs/recipes/bind-property-fact.md +58 -0
  85. package/docs/recipes/collection-slot.md +100 -0
  86. package/docs/recipes/fill-spec-decision.md +66 -0
  87. package/docs/recipes/fixture-states.md +68 -0
  88. package/docs/recipes/image-slot-crop.md +98 -0
  89. package/docs/recipes/interactive-island.md +100 -0
  90. package/docs/recipes/organize-section-folder.md +72 -0
  91. package/docs/recipes/prepare-submission.md +55 -0
  92. package/docs/recipes/second-template.md +49 -0
  93. package/docs/recipes/select-slot.md +59 -0
  94. package/docs/recipes/static-asset.md +103 -0
  95. package/docs/recipes/third-party-package.md +90 -0
  96. package/docs/rules/INDEX.md +4 -2
  97. package/docs/rules/bundle-binary-asset.md +33 -52
  98. package/docs/rules/bundle-incomplete.md +2 -2
  99. package/docs/rules/css-reason.md +3 -3
  100. package/docs/rules/no-bare-css-import.md +8 -8
  101. package/docs/rules/no-css-import-from-render-path.md +7 -8
  102. package/docs/rules/size-assets.md +88 -0
  103. package/docs/rules/size-island-bundle.md +123 -0
  104. package/docs/rules/size-section-css.md +19 -14
  105. package/docs/schema-system.md +28 -28
  106. package/docs/theme-and-css.md +2 -2
  107. package/docs/vocabulary.md +98 -0
  108. package/package.json +12 -3
  109. package/tsconfig.json +1 -1
@@ -0,0 +1,91 @@
1
+ import { compileThemeToCss } from "../../design-system/theme.js";
2
+ import { loadEsbuild } from "../check/resolve-tool.js";
3
+ import { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH, sectionCssFiles } from "../check/css.js";
4
+ import { runTailwind } from "./tailwind.js";
5
+ import { basename, join, relative } from "node:path";
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+
8
+ //#region src/cli/dev/build-css.ts
9
+ /**
10
+ * The Tailwind entry owns `@import "tailwindcss"` exactly once; `source(none)`
11
+ * disables auto content-detection so only the enumerated section @sources are
12
+ * scanned; the kit's styles.css brings the token preamble and opts its own
13
+ * primitives back in. Every path is emitted relative to `entryDir` (where the
14
+ * entry is written) so it resolves from that location.
15
+ */
16
+ function buildTailwindEntry(entryDir, sectionDirs) {
17
+ const rel = (target) => {
18
+ const path = relative(entryDir, target);
19
+ return path.startsWith(".") ? path : `./${path}`;
20
+ };
21
+ return [
22
+ `@import "tailwindcss" source(none);`,
23
+ `@import "@homepages/template-kit/styles.css";`,
24
+ ...sectionDirs.map((d) => `@source "${rel(d)}/**/*.tsx";`),
25
+ ""
26
+ ].join("\n");
27
+ }
28
+ async function compileUtilities(root, sectionDirs) {
29
+ const cacheDir = join(root, ".tk-dev-cache");
30
+ await mkdir(cacheDir, { recursive: true });
31
+ const input = join(cacheDir, "tw-entry.css");
32
+ const output = join(cacheDir, "tw-out.css");
33
+ await writeFile(input, buildTailwindEntry(cacheDir, sectionDirs), "utf8");
34
+ await runTailwind(root, {
35
+ input,
36
+ output
37
+ });
38
+ return readFile(output, "utf8");
39
+ }
40
+ /**
41
+ * Bundle every section's hand-written CSS through esbuild's `file` loader, so
42
+ * each `url()` asset ref is rewritten to `/section-assets/<name>-<hash>.<ext>`
43
+ * (production's form) and the referenced bytes are captured. CSS files are
44
+ * concatenated in `sectionCssFiles` order (styles.css first). Assets are keyed
45
+ * by emitted filename, which is content-hashed — so the same asset referenced
46
+ * from two sections dedupes to one entry.
47
+ */
48
+ async function compileSectionHandCss(root, sectionDirs) {
49
+ const { build } = await loadEsbuild(root);
50
+ const assets = /* @__PURE__ */ new Map();
51
+ const parts = [];
52
+ for (const dir of sectionDirs) for (const cssFile of await sectionCssFiles(dir)) {
53
+ const result = await build({
54
+ entryPoints: [cssFile],
55
+ bundle: true,
56
+ write: false,
57
+ outdir: "dist",
58
+ loader: {
59
+ ".css": "css",
60
+ ...ASSET_LOADERS
61
+ },
62
+ publicPath: ASSET_PUBLIC_PATH,
63
+ assetNames: ASSET_NAMES,
64
+ absWorkingDir: root,
65
+ logLevel: "silent"
66
+ });
67
+ for (const file of result.outputFiles ?? []) if (file.path.endsWith(".css")) parts.push(file.text);
68
+ else assets.set(basename(file.path), file.contents);
69
+ }
70
+ return {
71
+ css: parts.join("\n"),
72
+ assets
73
+ };
74
+ }
75
+ /** theme :root/@theme custom props, the compiled Tailwind utilities, then each
76
+ * section's hand-CSS with its `url()` asset refs rewritten to servable paths. */
77
+ async function assembleTemplateCss(root, theme, sectionDirs) {
78
+ const utilities = await compileUtilities(root, sectionDirs);
79
+ const sections = await compileSectionHandCss(root, sectionDirs);
80
+ return {
81
+ css: [
82
+ compileThemeToCss(theme),
83
+ utilities,
84
+ sections.css
85
+ ].filter(Boolean).join("\n"),
86
+ assets: sections.assets
87
+ };
88
+ }
89
+
90
+ //#endregion
91
+ export { assembleTemplateCss, buildTailwindEntry };
@@ -0,0 +1,35 @@
1
+ import { VERIFICATION_WIDTHS } from "../../design-system/breakpoints.js";
2
+ import { loadSectionBundle } from "../../ssr.js";
3
+ import { discoverSections } from "./discover.js";
4
+ import { join } from "node:path";
5
+
6
+ //#region src/cli/dev/catalog.ts
7
+ async function buildCatalog(vite, workspace) {
8
+ const load = (dir, file) => vite.ssrLoadModule(join(dir, file));
9
+ const templates = [];
10
+ for (const template of workspace.templates) {
11
+ const sections = [];
12
+ for (const section of await discoverSections(template.dir)) try {
13
+ const { invariantFixtures } = await loadSectionBundle(section.dir, load);
14
+ sections.push({
15
+ key: section.name,
16
+ fixtures: invariantFixtures.map((f) => ({
17
+ id: f.id,
18
+ label: f.label
19
+ }))
20
+ });
21
+ } catch {}
22
+ templates.push({
23
+ key: template.key,
24
+ sections
25
+ });
26
+ }
27
+ return {
28
+ templates,
29
+ breakpoints: [...VERIFICATION_WIDTHS],
30
+ defaultTemplate: workspace.cwdTemplate
31
+ };
32
+ }
33
+
34
+ //#endregion
35
+ export { buildCatalog };
@@ -0,0 +1,38 @@
1
+ import { ATTR_SECTION_INSTANCE_ID } from "../../contracts/markers.js";
2
+ import { buildSectionIslandMap } from "./island-map.js";
3
+ import { resolveManifestInstances } from "./manifest-instances.js";
4
+ import { renderSection } from "./render-section.js";
5
+ import { wrapSectionHtml } from "./section-page.js";
6
+
7
+ //#region src/cli/dev/compose-template.ts
8
+ function escapeAttr(value) {
9
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
10
+ }
11
+ function wrapInstance(instanceId, label, html) {
12
+ return `<div ${ATTR_SECTION_INSTANCE_ID}="${escapeAttr(instanceId)}" data-section-label="${escapeAttr(label)}">${html}</div>`;
13
+ }
14
+ async function composeTemplatePage(vite, args) {
15
+ const { workspace, template, islandBootstrapUrl, canvas = false } = args;
16
+ const instances = await resolveManifestInstances(template);
17
+ const bodies = [];
18
+ const islands = {};
19
+ for (const inst of instances) {
20
+ const { html } = await renderSection(vite, {
21
+ sectionDir: inst.sectionDir,
22
+ fixture: inst.fixture,
23
+ override: Object.keys(inst.options).length > 0 ? { options: inst.options } : void 0
24
+ });
25
+ bodies.push(canvas ? wrapInstance(inst.instanceId, inst.section, html) : html);
26
+ Object.assign(islands, await buildSectionIslandMap(workspace.root, workspace.templatesDir, inst.sectionDir));
27
+ }
28
+ return wrapSectionHtml({
29
+ sectionHtml: bodies.join("\n"),
30
+ cssRev: "0",
31
+ template: template.key,
32
+ islands,
33
+ bootstrapUrl: islandBootstrapUrl
34
+ });
35
+ }
36
+
37
+ //#endregion
38
+ export { composeTemplatePage };
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/cli/dev/content-override.ts
4
+ const MAX_URL_OVERRIDE_BYTES = 16 * 1024;
5
+ const ContentOverrideSchema = z.object({
6
+ slots: z.record(z.string(), z.unknown()).optional(),
7
+ options: z.record(z.string(), z.unknown()).optional(),
8
+ nav: z.object({
9
+ selfAnchor: z.string().optional(),
10
+ anchors: z.record(z.string(), z.unknown()).optional()
11
+ }).strict().optional(),
12
+ variants: z.record(z.string(), z.string()).optional()
13
+ }).strict();
14
+ function isPlainObject(v) {
15
+ return typeof v === "object" && v !== null && !Array.isArray(v);
16
+ }
17
+ /**
18
+ * Merge `patch` onto `base`: plain objects recurse key-by-key; anything else
19
+ * (scalar, array, or `null`) replaces. Keys absent from `patch` keep the `base`
20
+ * value. Pure — never mutates its inputs.
21
+ */
22
+ function deepMerge(base, patch) {
23
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
24
+ const out = { ...base };
25
+ for (const [k, v] of Object.entries(patch)) out[k] = k in base ? deepMerge(base[k], v) : v;
26
+ return out;
27
+ }
28
+ /** Deep-merge an override onto resolved props. `undefined` returns `base` as-is. */
29
+ function applyContentOverride(base, override) {
30
+ if (!override) return base;
31
+ return {
32
+ slots: deepMerge(base.slots, override.slots ?? {}),
33
+ options: deepMerge(base.options, override.options ?? {}),
34
+ nav: deepMerge(base.nav, override.nav ?? {}),
35
+ variants: deepMerge(base.variants, override.variants ?? {})
36
+ };
37
+ }
38
+ /** Validate untrusted input into a `ContentOverride`. `undefined`/absent → `undefined`. */
39
+ function parseContentOverride(input) {
40
+ if (input === void 0 || input === null) return void 0;
41
+ return ContentOverrideSchema.parse(input);
42
+ }
43
+ /** Decode a base64 `?overrides=` URL param, enforce the URL ceiling, and validate. */
44
+ function decodeUrlOverride(raw) {
45
+ if (!raw) return void 0;
46
+ const text = Buffer.from(raw, "base64").toString("utf8");
47
+ if (Buffer.byteLength(text, "utf8") > 16384) throw new Error(`overrides payload exceeds ${MAX_URL_OVERRIDE_BYTES} bytes`);
48
+ let json;
49
+ try {
50
+ json = JSON.parse(text);
51
+ } catch (err) {
52
+ throw new Error(`invalid overrides payload: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ return parseContentOverride(json);
55
+ }
56
+
57
+ //#endregion
58
+ export { MAX_URL_OVERRIDE_BYTES, applyContentOverride, decodeUrlOverride, deepMerge, parseContentOverride };
@@ -0,0 +1,35 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readdir } from "node:fs/promises";
4
+
5
+ //#region src/cli/dev/discover.ts
6
+ const REQUIRED_SECTION_FILES = [
7
+ "Renderer.tsx",
8
+ "schema.ts",
9
+ "fill-spec.ts",
10
+ "fixtures.ts"
11
+ ];
12
+ /**
13
+ * Every subdirectory of `<templateDir>/sections` that carries all four contract
14
+ * files, sorted by name. A folder missing any of the four is not a section and
15
+ * is skipped (not an error — it may be a work in progress).
16
+ */
17
+ async function discoverSections(templateDir) {
18
+ const sectionsDir = join(templateDir, "sections");
19
+ if (!existsSync(sectionsDir)) return [];
20
+ const entries = await readdir(sectionsDir, { withFileTypes: true });
21
+ const out = [];
22
+ for (const entry of entries) {
23
+ if (!entry.isDirectory()) continue;
24
+ const dir = join(sectionsDir, entry.name);
25
+ if (REQUIRED_SECTION_FILES.every((file) => existsSync(join(dir, file)))) out.push({
26
+ name: entry.name,
27
+ dir
28
+ });
29
+ }
30
+ out.sort((a, b) => a.name.localeCompare(b.name));
31
+ return out;
32
+ }
33
+
34
+ //#endregion
35
+ export { REQUIRED_SECTION_FILES, discoverSections };
@@ -0,0 +1,75 @@
1
+ //#region src/cli/dev/fill-state.ts
2
+ const LIST_TYPES = /* @__PURE__ */ new Set([
3
+ "list",
4
+ "poi_list",
5
+ "image_collection",
6
+ "object_list"
7
+ ]);
8
+ /** Whether a resolved slot value carries real content for its type. */
9
+ function slotHasContent(value, type) {
10
+ if (value === null || value === void 0) return false;
11
+ if (LIST_TYPES.has(type)) return Array.isArray(value) && value.length > 0;
12
+ if (type === "image") return typeof value === "object" && Boolean(value.url);
13
+ if (typeof value === "string") return value.trim().length > 0;
14
+ return true;
15
+ }
16
+ /** Every `data-slot-id` in the rendered tree. */
17
+ function collectRenderedSlotIds(root) {
18
+ const ids = /* @__PURE__ */ new Set();
19
+ const walk = (node) => {
20
+ if (node.slotId) ids.add(node.slotId);
21
+ node.children.forEach(walk);
22
+ };
23
+ walk(root);
24
+ return ids;
25
+ }
26
+ /**
27
+ * Best-effort line number (1-based) of each `<slotId>:` declaration inside the
28
+ * `slots:` block of a section's schema.ts source. A slot not located is omitted.
29
+ */
30
+ function schemaSlotLines(source, slotIds) {
31
+ const lines = source.split("\n");
32
+ const slotsAt = lines.findIndex((l) => /(^|\s)slots\s*:/.test(l));
33
+ const out = {};
34
+ if (slotsAt < 0) return out;
35
+ for (const id of slotIds) {
36
+ const re = new RegExp(`^\\s*${id}\\s*:`);
37
+ const idx = lines.findIndex((l, i) => i > slotsAt && re.test(l));
38
+ if (idx >= 0) out[id] = idx + 1;
39
+ }
40
+ return out;
41
+ }
42
+ function truncate(s, n = 80) {
43
+ return s.length > n ? s.slice(0, n) + "…" : s;
44
+ }
45
+ function valuePreview(value, type) {
46
+ if (LIST_TYPES.has(type)) return Array.isArray(value) ? `[${value.length} items]` : void 0;
47
+ if (type === "image") {
48
+ const url = value?.url;
49
+ return url ? `image:${truncate(url)}` : void 0;
50
+ }
51
+ if (typeof value === "string") return truncate(value);
52
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
53
+ }
54
+ function computeSlotFills(args) {
55
+ const { declaredSlots, resolvedSlots, renderedSlotIds, slotLines } = args;
56
+ return Object.entries(declaredSlots).map(([id, def]) => {
57
+ const required = def.required ?? false;
58
+ const has = slotHasContent(resolvedSlots[id], def.type);
59
+ const fillState = has ? "filled" : required ? "missing" : "empty";
60
+ const preview = has ? valuePreview(resolvedSlots[id], def.type) : void 0;
61
+ const line = slotLines[id];
62
+ return {
63
+ id,
64
+ type: def.type,
65
+ required,
66
+ fillState,
67
+ rendered: renderedSlotIds.has(id),
68
+ ...preview !== void 0 ? { value: preview } : {},
69
+ at: line ? `schema.ts:${line}` : "schema.ts"
70
+ };
71
+ });
72
+ }
73
+
74
+ //#endregion
75
+ export { collectRenderedSlotIds, computeSlotFills, schemaSlotLines, slotHasContent };
@@ -0,0 +1,32 @@
1
+ import { startDevServer } from "./server.js";
2
+
3
+ //#region src/cli/dev/index.ts
4
+ const DEFAULT_PORT = 5180;
5
+ async function runDev(argv, cwd) {
6
+ const server = await startDevServer({
7
+ cwd,
8
+ port: parsePort(argv) ?? DEFAULT_PORT
9
+ });
10
+ process.stdout.write(`template-kit dev — ${server.workspace.templates.length} template(s) at ${server.url}\n`);
11
+ for (const template of server.workspace.templates) process.stdout.write(` ${template.key}: ${server.url}/sections/${template.key}/<section>/<fixture>\n`);
12
+ await new Promise((resolve) => {
13
+ const stop = () => {
14
+ server.close().then(resolve);
15
+ };
16
+ process.once("SIGINT", stop);
17
+ process.once("SIGTERM", stop);
18
+ });
19
+ return 0;
20
+ }
21
+ function parsePort(argv) {
22
+ const i = argv.indexOf("--port");
23
+ if (i >= 0) {
24
+ const raw = argv[i + 1];
25
+ const n = raw !== void 0 ? Number(raw) : NaN;
26
+ if (Number.isInteger(n) && n > 0) return n;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ //#endregion
32
+ export { runDev };
@@ -0,0 +1,38 @@
1
+ import { renderSection } from "./render-section.js";
2
+ import { collectRenderedSlotIds, computeSlotFills, schemaSlotLines } from "./fill-state.js";
3
+ import { summarizeHtml } from "./structure-summary.js";
4
+ import { join } from "node:path";
5
+ import { readFile } from "node:fs/promises";
6
+
7
+ //#region src/cli/dev/inspect.ts
8
+ async function inspectSection(vite, args) {
9
+ const result = await renderSection(vite, {
10
+ sectionDir: args.sectionDir,
11
+ fixture: args.fixture,
12
+ override: args.override
13
+ });
14
+ const { tree, text } = summarizeHtml(result.html);
15
+ const schemaSource = await readFile(join(args.sectionDir, "schema.ts"), "utf8");
16
+ const slots = computeSlotFills({
17
+ declaredSlots: result.schemaSlots,
18
+ resolvedSlots: result.resolvedProps.slots,
19
+ renderedSlotIds: collectRenderedSlotIds(tree),
20
+ slotLines: schemaSlotLines(schemaSource, Object.keys(result.schemaSlots))
21
+ });
22
+ return {
23
+ target: {
24
+ template: args.template,
25
+ section: args.section,
26
+ fixture: args.fixture,
27
+ schemaFile: `sections/${args.section}/schema.ts`
28
+ },
29
+ slots,
30
+ options: result.resolvedProps.options,
31
+ variants: result.resolvedProps.variants,
32
+ nullCorpus: result.nullCorpus,
33
+ ...args.withOutline ? { outline: text } : {}
34
+ };
35
+ }
36
+
37
+ //#endregion
38
+ export { inspectSection };
@@ -0,0 +1,38 @@
1
+ import { join } from "node:path";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+
4
+ //#region src/cli/dev/island-bootstrap.ts
5
+ const CACHE_DIR = ".tk-dev-cache";
6
+ const BOOTSTRAP_FILE = "island-bootstrap.js";
7
+ const BOOTSTRAP_SOURCE = `import { hydrateIslands } from "@homepages/template-kit/island-runtime";
8
+
9
+ const modules = import.meta.glob("/templates/**/*.{tsx,jsx}");
10
+ const map = window.__TR_ISLAND_MAP || {};
11
+
12
+ hydrateIslands({
13
+ load: (key) => {
14
+ const url = map[key];
15
+ const importer = url ? modules[url] : undefined;
16
+ if (!importer) return Promise.reject(new Error("template-kit dev: no island module for " + key));
17
+ return importer();
18
+ },
19
+ onError: (error, ctx) => {
20
+ console.error("template-kit dev: island failed to hydrate:", ctx.key, error);
21
+ },
22
+ });
23
+ `;
24
+ /**
25
+ * Write the shared bootstrap into the workspace cache dir and return the URL the
26
+ * page loads it from. It lives under the root so Vite serves + transforms it (the
27
+ * bare `island-runtime` import and the glob), reached via Vite's `/@fs/` prefix.
28
+ */
29
+ async function writeIslandBootstrap(root) {
30
+ const dir = join(root, CACHE_DIR);
31
+ await mkdir(dir, { recursive: true });
32
+ const abs = join(dir, BOOTSTRAP_FILE);
33
+ await writeFile(abs, BOOTSTRAP_SOURCE, "utf8");
34
+ return `/@fs/${abs}`;
35
+ }
36
+
37
+ //#endregion
38
+ export { writeIslandBootstrap };
@@ -0,0 +1,35 @@
1
+ import { islandKey } from "../../islands/detect.js";
2
+ import { findSectionIslands } from "../../islands/discover.js";
3
+ import { basename, extname, relative, sep } from "node:path";
4
+
5
+ //#region src/cli/dev/island-map.ts
6
+ /**
7
+ * Derive an island key from a client component's absolute path, relative to the
8
+ * workspace's `templates/` dir: `<templateFolder>/<section>:<ComponentName>`.
9
+ * A component name is the file basename. Returns null for a path that is not a
10
+ * `templates/<folder>/sections/<section>/…` component.
11
+ */
12
+ function islandKeyForFile(templatesDir, absFile) {
13
+ const rel = relative(templatesDir, absFile);
14
+ if (rel.startsWith("..")) return null;
15
+ const parts = rel.split(sep);
16
+ if (parts.length < 4 || parts[1] !== "sections") return null;
17
+ return islandKey(`${parts[0]}/${parts[2]}`, basename(absFile, extname(absFile)));
18
+ }
19
+ /**
20
+ * The map the browser bootstrap reads: island key → the module's root-relative
21
+ * URL (what Vite serves it at, and what `import.meta.glob("/templates/**")` keys
22
+ * it by). Empty when the section has no islands.
23
+ */
24
+ async function buildSectionIslandMap(root, templatesDir, sectionDir) {
25
+ const map = {};
26
+ for (const file of await findSectionIslands(sectionDir)) {
27
+ const key = islandKeyForFile(templatesDir, file);
28
+ if (!key) continue;
29
+ map[key] = "/" + relative(root, file).split(sep).join("/");
30
+ }
31
+ return map;
32
+ }
33
+
34
+ //#endregion
35
+ export { buildSectionIslandMap, islandKeyForFile };
@@ -0,0 +1,31 @@
1
+ import { wrapClientModuleMagic } from "../../islands/wrap.js";
2
+ import { islandKeyForFile } from "./island-map.js";
3
+ import { extname, join, sep } from "node:path";
4
+
5
+ //#region src/cli/dev/island-transform.ts
6
+ const COMPONENT_EXTS = /* @__PURE__ */ new Set([".tsx", ".jsx"]);
7
+ function islandTransformPlugin(opts) {
8
+ const templatesDir = join(opts.root, "templates");
9
+ const templatesPrefix = templatesDir + sep;
10
+ return {
11
+ name: "template-kit:island-transform",
12
+ enforce: "pre",
13
+ transform(code, id, options) {
14
+ if (!options?.ssr) return null;
15
+ const file = id.split("?", 1)[0] ?? id;
16
+ if (!file.startsWith(templatesPrefix)) return null;
17
+ if (!COMPONENT_EXTS.has(extname(file))) return null;
18
+ const key = islandKeyForFile(templatesDir, file);
19
+ if (!key) return null;
20
+ const s = wrapClientModuleMagic(code, key);
21
+ if (s === null) return null;
22
+ return {
23
+ code: s.toString(),
24
+ map: s.generateMap({ hires: true })
25
+ };
26
+ }
27
+ };
28
+ }
29
+
30
+ //#endregion
31
+ export { islandTransformPlugin };
@@ -0,0 +1,31 @@
1
+ import { TemplateManifest } from "../../schema/manifest.js";
2
+ import { discoverSections } from "./discover.js";
3
+ import { readFile } from "node:fs/promises";
4
+
5
+ //#region src/cli/dev/manifest-instances.ts
6
+ function selectManifestInstances(sections, manifestSections) {
7
+ const out = [];
8
+ for (const inst of manifestSections) {
9
+ const info = sections.find((s) => s.name === inst.section);
10
+ if (!info) continue;
11
+ out.push({
12
+ instanceId: inst.instance_id,
13
+ section: inst.section,
14
+ sectionDir: info.dir,
15
+ fixture: "typical",
16
+ options: inst.options
17
+ });
18
+ }
19
+ return out;
20
+ }
21
+ async function resolveManifestInstances(template) {
22
+ const manifest = TemplateManifest.parse(JSON.parse(await readFile(template.manifestPath, "utf8")));
23
+ return selectManifestInstances(await discoverSections(template.dir), manifest.sections);
24
+ }
25
+ /** The single manifest instance with this id, or null (author WIP / unknown id). */
26
+ async function findManifestInstance(template, instanceId) {
27
+ return (await resolveManifestInstances(template)).find((i) => i.instanceId === instanceId) ?? null;
28
+ }
29
+
30
+ //#endregion
31
+ export { findManifestInstance, resolveManifestInstances, selectManifestInstances };
@@ -0,0 +1,24 @@
1
+ import { loadSectionBundle, renderSectionHtml } from "../../ssr.js";
2
+ import { applyContentOverride } from "./content-override.js";
3
+ import { projectSlotSchema } from "./slot-schema.js";
4
+ import { join } from "node:path";
5
+
6
+ //#region src/cli/dev/render-section.ts
7
+ async function renderSection(vite, req) {
8
+ const load = (dir, file) => vite.ssrLoadModule(join(dir, file));
9
+ const { Renderer, invariantFixtures, schemaSlots } = await loadSectionBundle(req.sectionDir, load);
10
+ const fx = invariantFixtures.find((f) => f.id === req.fixture);
11
+ if (!fx) throw new Error(`fixture "${req.fixture}" not found (have: ${invariantFixtures.map((r) => r.id).join(", ")})`);
12
+ const props = applyContentOverride(fx.props, req.override);
13
+ return {
14
+ html: renderSectionHtml(Renderer, props),
15
+ resolvedProps: props,
16
+ fixtureLabel: fx.label,
17
+ slotSchema: projectSlotSchema(schemaSlots),
18
+ schemaSlots,
19
+ nullCorpus: invariantFixtures.filter((f) => f.id.startsWith("null_")).map((f) => f.id)
20
+ };
21
+ }
22
+
23
+ //#endregion
24
+ export { renderSection };
@@ -0,0 +1,24 @@
1
+ //#region src/cli/dev/screenshot-target.ts
2
+ function resolveScreenshotPath(t) {
3
+ if (!t.template) return {
4
+ ok: false,
5
+ error: "screenshot requires a template"
6
+ };
7
+ const hasSection = Boolean(t.section);
8
+ if (hasSection !== Boolean(t.fixture)) return {
9
+ ok: false,
10
+ error: "provide section and fixture together for a section screenshot, or neither for a full-template screenshot"
11
+ };
12
+ const enc = encodeURIComponent;
13
+ if (hasSection) return {
14
+ ok: true,
15
+ path: `/sections/${enc(t.template)}/${enc(t.section)}/${enc(t.fixture)}`
16
+ };
17
+ return {
18
+ ok: true,
19
+ path: `/template/${enc(t.template)}`
20
+ };
21
+ }
22
+
23
+ //#endregion
24
+ export { resolveScreenshotPath };
@@ -0,0 +1,54 @@
1
+ //#region src/cli/dev/section-page.ts
2
+ function wrapSectionHtml(opts) {
3
+ const t = encodeURIComponent(opts.template);
4
+ const v = encodeURIComponent(opts.cssRev);
5
+ return `<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8" />
9
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
10
+ <title>Section Preview</title>
11
+ <link rel="stylesheet" href="/runtime/base.css?v=${v}" />
12
+ <link rel="stylesheet" href="/runtime/template.css?template=${t}&v=${v}" />
13
+ <style>html, body { margin: 0; padding: 0; background: #fff; }</style>
14
+ </head>
15
+ <body>
16
+ <div id="section-host">${opts.sectionHtml}</div>${islandBootstrap(opts.islands, opts.bootstrapUrl)}
17
+ <script>${HMR_CLIENT_JS}<\/script>
18
+ </body>
19
+ </html>`;
20
+ }
21
+ function islandBootstrap(islands, bootstrapUrl) {
22
+ if (Object.keys(islands).length === 0) return "";
23
+ const mapJson = JSON.stringify(islands).replace(/</g, "\\u003c");
24
+ return `
25
+ ${REACT_REFRESH_PREAMBLE}
26
+ <script>window.__TR_ISLAND_MAP = ${mapJson};<\/script>
27
+ <script type="module" src="${bootstrapUrl}"><\/script>`;
28
+ }
29
+ const REACT_REFRESH_PREAMBLE = `<script type="module">
30
+ import RefreshRuntime from "/@react-refresh";
31
+ RefreshRuntime.injectIntoGlobalHook(window);
32
+ window.$RefreshReg$ = () => {};
33
+ window.$RefreshSig$ = () => (type) => type;
34
+ window.__vite_plugin_react_preamble_installed__ = true;
35
+ <\/script>`;
36
+ const HMR_CLIENT_JS = `
37
+ (function () {
38
+ try {
39
+ var src = new EventSource("/__hmr");
40
+ src.addEventListener("section:changed", function () { window.location.reload(); });
41
+ src.addEventListener("tailwind:changed", function () {
42
+ var link = document.querySelector('link[href*="/runtime/template.css"]');
43
+ if (link) {
44
+ var href = link.getAttribute("href");
45
+ var base = href.split("&v=")[0];
46
+ link.setAttribute("href", base + "&v=" + Date.now());
47
+ }
48
+ });
49
+ } catch (e) { /* dev-only; ignore */ }
50
+ })();
51
+ `.trim();
52
+
53
+ //#endregion
54
+ export { wrapSectionHtml };