@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.
- package/CHANGELOG.md +222 -0
- package/README.md +96 -17
- package/dist/asset-modules.d.ts +81 -0
- package/dist/base.css +9 -0
- package/dist/cli/check/config.js +41 -0
- package/dist/cli/check/css.js +131 -0
- package/dist/cli/check/diagnostics.js +32 -0
- package/dist/cli/check/index.js +87 -0
- package/dist/cli/check/loader.js +146 -0
- package/dist/cli/check/paths.js +15 -0
- package/dist/cli/check/relativize.js +18 -0
- package/dist/cli/check/render-invariants.js +24 -0
- package/dist/cli/check/resolve-tool.js +38 -0
- package/dist/cli/check/section-assets.js +58 -0
- package/dist/cli/check/stages/deps.js +337 -0
- package/dist/cli/check/stages/lint.js +46 -0
- package/dist/cli/check/stages/render.js +101 -0
- package/dist/cli/check/stages/size.js +274 -0
- package/dist/cli/check/stages/tree.js +206 -0
- package/dist/cli/check/stages/typecheck.js +46 -0
- package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
- package/dist/cli/check/stages/validate/facts.js +18 -0
- package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
- package/dist/cli/check/stages/validate/group-contract.js +21 -0
- package/dist/cli/check/stages/validate/index.js +66 -0
- package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
- package/dist/cli/check/stages/validate/nav-contract.js +93 -0
- package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
- package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
- package/dist/cli/check/stages/validate/orchestrator.js +241 -0
- package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
- package/dist/cli/check/stages/validate/row-contract.js +31 -0
- package/dist/cli/check/stages/validate/select-contract.js +13 -0
- package/dist/cli/check/stages/validate/source-contract.js +21 -0
- package/dist/cli/check/stages/validate/variant-contract.js +15 -0
- package/dist/cli/check/workspace.js +86 -0
- package/dist/cli/dev/build-css.js +91 -0
- package/dist/cli/dev/catalog.js +35 -0
- package/dist/cli/dev/compose-template.js +38 -0
- package/dist/cli/dev/content-override.js +58 -0
- package/dist/cli/dev/discover.js +35 -0
- package/dist/cli/dev/fill-state.js +75 -0
- package/dist/cli/dev/index.js +32 -0
- package/dist/cli/dev/inspect.js +38 -0
- package/dist/cli/dev/island-bootstrap.js +38 -0
- package/dist/cli/dev/island-map.js +35 -0
- package/dist/cli/dev/island-transform.js +31 -0
- package/dist/cli/dev/manifest-instances.js +31 -0
- package/dist/cli/dev/render-section.js +24 -0
- package/dist/cli/dev/screenshot-target.js +24 -0
- package/dist/cli/dev/section-page.js +54 -0
- package/dist/cli/dev/server.js +480 -0
- package/dist/cli/dev/slot-schema.js +12 -0
- package/dist/cli/dev/structure-summary.js +118 -0
- package/dist/cli/dev/tailwind.js +32 -0
- package/dist/cli/dev/vite-server.js +33 -0
- package/dist/cli/dev/workspace.js +63 -0
- package/dist/cli/link/index.js +74 -0
- package/dist/cli/link/overlay.js +59 -0
- package/dist/cli/link/watch.js +25 -0
- package/dist/cli/new/emit.js +48 -0
- package/dist/cli/new/index.js +69 -0
- package/dist/cli/new/scaffold/section/_Renderer.tsx +20 -0
- package/dist/cli/new/scaffold/section/_fill-spec.ts +18 -0
- package/dist/cli/new/scaffold/section/_fixtures.ts +13 -0
- package/dist/cli/new/scaffold/section/_schema.ts +24 -0
- package/dist/cli/new/scaffold/template/_manifest.json +9 -0
- package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
- package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +40 -0
- package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +14 -0
- package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +29 -0
- package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +41 -0
- package/dist/cli/new/scaffold/template/sections/hero/schema.ts +60 -0
- package/dist/cli/new/scaffold/template/theme.css +24 -0
- package/dist/cli/new/scaffold/template/theme.ts +27 -0
- package/dist/cli/new/scaffold-assets.js +20 -0
- package/dist/cli/pack/collect.js +44 -0
- package/dist/cli/pack/guards.js +57 -0
- package/dist/cli/pack/index.js +66 -0
- package/dist/cli/pack/manifest.js +15 -0
- package/dist/cli/pack/zip.js +86 -0
- package/dist/cli/theme/generate.js +43 -0
- package/dist/cli/theme/index.js +33 -0
- package/dist/cli/theme/load-theme.js +69 -0
- package/dist/cli.js +100 -16
- package/dist/contracts/fill-treatments.js +47 -0
- package/dist/design-system/theme.d.ts +30 -300
- package/dist/design-system/theme.js +112 -90
- package/dist/dev-client/assets/index-DfEfrp6P.css +1 -0
- package/dist/dev-client/assets/index-bpMP0b0W.js +50 -0
- package/dist/dev-client/index.html +13 -0
- package/dist/eslint/is-client-file.d.ts +4 -0
- package/dist/eslint/rules/no-hex.js +78 -6
- package/dist/eslint.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/islands/detect.d.ts +16 -0
- package/dist/islands/detect.js +22 -0
- package/dist/islands/discover.d.ts +5 -0
- package/dist/islands/discover.js +19 -0
- package/dist/islands/esbuild-plugin.d.ts +13 -0
- package/dist/islands/esbuild-plugin.js +39 -0
- package/dist/islands/wrap.d.ts +11 -0
- package/dist/islands/wrap.js +48 -0
- package/dist/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.js +77 -0
- package/dist/node_modules/magic-string/dist/magic-string.es.js +939 -0
- package/dist/package.js +1 -1
- package/dist/primitives/Image.js +1 -1
- package/dist/schema/fill-spec.d.ts +80 -590
- package/dist/schema/fixture-schema.d.ts +5 -81
- package/dist/schema/manifest.d.ts +39 -475
- package/dist/schema/resolve-section-ref.js +10 -0
- package/dist/schema/rows.js +10 -0
- package/dist/schema/section-nav.js +9 -0
- package/dist/schema/section-schema.d.ts +51 -437
- package/dist/schema/slot-types.d.ts +12 -16
- package/dist/ssr.d.ts +31 -0
- package/dist/ssr.js +46 -0
- package/dist/styles.css +31 -88
- package/docs/INDEX.md +12 -3
- package/docs/assets.md +128 -0
- package/docs/check.md +124 -0
- package/docs/dev.md +187 -0
- package/docs/eslint.md +18 -8
- package/docs/islands.md +5 -2
- package/docs/llms.txt +59 -9
- package/docs/new.md +27 -0
- package/docs/overview.md +57 -0
- package/docs/pack.md +46 -0
- package/docs/primitives.md +10 -6
- package/docs/quickstart.md +102 -0
- package/docs/recipes/INDEX.md +27 -0
- package/docs/recipes/bind-property-fact.md +58 -0
- package/docs/recipes/collection-slot.md +100 -0
- package/docs/recipes/fill-spec-decision.md +66 -0
- package/docs/recipes/fixture-states.md +68 -0
- package/docs/recipes/image-slot-crop.md +98 -0
- package/docs/recipes/interactive-island.md +100 -0
- package/docs/recipes/organize-section-folder.md +72 -0
- package/docs/recipes/prepare-submission.md +55 -0
- package/docs/recipes/second-template.md +49 -0
- package/docs/recipes/select-slot.md +59 -0
- package/docs/recipes/static-asset.md +103 -0
- package/docs/recipes/third-party-package.md +90 -0
- package/docs/rules/INDEX.md +14 -5
- package/docs/rules/bundle-binary-asset.md +83 -0
- package/docs/rules/bundle-incomplete.md +2 -2
- package/docs/rules/css-reason.md +3 -3
- package/docs/rules/fixtures-invalid.md +96 -0
- package/docs/rules/license-denied.md +11 -3
- package/docs/rules/manifest-invalid.md +99 -0
- package/docs/rules/no-bare-css-import.md +8 -8
- package/docs/rules/no-css-import-from-render-path.md +7 -8
- package/docs/rules/no-hex.md +76 -10
- package/docs/rules/no-templates.md +87 -0
- package/docs/rules/parse-error.md +76 -0
- package/docs/rules/schema-invalid.md +96 -0
- package/docs/rules/size-assets.md +88 -0
- package/docs/rules/size-island-bundle.md +123 -0
- package/docs/rules/size-section-css.md +19 -14
- package/docs/schema-system.md +28 -28
- package/docs/theme-and-css.md +157 -92
- package/docs/vocabulary.md +98 -0
- package/package.json +14 -11
- 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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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 };
|