@homepages/create-workspace 0.8.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/dist/index.js ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // `npm create @homepages/workspace <name>` — stamp a workspace container: shared
3
+ // root context + one working starter template, then install and git-init.
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
6
+ import { mkdir } from "node:fs/promises";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { fileURLToPath } from "node:url";
10
+ import { stampTree } from "./stamp.js";
11
+ const ASSETS = join(dirname(fileURLToPath(import.meta.url)), "..", "scaffold");
12
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
13
+ export async function scaffoldWorkspace(name, targetDir, opts) {
14
+ await mkdir(targetDir, { recursive: true });
15
+ await stampTree(join(ASSETS, "workspace"), targetDir, {
16
+ WORKSPACE_NAME: name,
17
+ KIT_VERSION: opts.kitVersion,
18
+ });
19
+ await stampTree(join(ASSETS, "template"), join(targetDir, "templates", "starter"), {
20
+ TEMPLATE_KEY: "starter",
21
+ TEMPLATE_NAME: "Starter",
22
+ });
23
+ if (opts.git !== false)
24
+ execFileSync("git", ["init", "-q"], { cwd: targetDir, stdio: "inherit" });
25
+ if (opts.install !== false)
26
+ execFileSync("npm", ["install"], { cwd: targetDir, stdio: "inherit" });
27
+ }
28
+ /**
29
+ * The kit version to caret-pin: this scaffolder is published version-synced
30
+ * with the kit, so its own `version` IS the kit version. Read at runtime
31
+ * (rather than a static JSON import) because `../package.json` sits outside
32
+ * `rootDir: "src"` and trips TS6059 under a static import.
33
+ */
34
+ function kitVersion() {
35
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
36
+ const pkg = JSON.parse(raw);
37
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
38
+ }
39
+ async function promptName() {
40
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
41
+ try {
42
+ return (await rl.question("Workspace name: ")).trim();
43
+ }
44
+ finally {
45
+ rl.close();
46
+ }
47
+ }
48
+ async function main() {
49
+ const arg = process.argv[2];
50
+ const name = arg ?? (await promptName());
51
+ if (!NAME_RE.test(name)) {
52
+ process.stderr.write(`create-workspace: name must be kebab-case (${NAME_RE.source}). Got: ${name}\n`);
53
+ return 1;
54
+ }
55
+ const targetDir = resolve(process.cwd(), name);
56
+ if (existsSync(targetDir)) {
57
+ process.stderr.write(`create-workspace: ${name}/ already exists.\n`);
58
+ return 1;
59
+ }
60
+ await scaffoldWorkspace(name, targetDir, { kitVersion: kitVersion() });
61
+ process.stdout.write(`\nCreated ${name}/\n\n` +
62
+ ` cd ${name}\n` +
63
+ ` npm run dev # preview the starter (the island hydrates)\n` +
64
+ ` npm run check # typecheck + lint + validate\n` +
65
+ ` npx template-kit new <name> # add another template\n\n` +
66
+ `Read templates/starter/sections/hero/ — it teaches the whole contract.\n`);
67
+ return 0;
68
+ }
69
+ // Only run main() when invoked as the bin, not when imported by a test.
70
+ //
71
+ // realpath argv[1] before comparing: npm installs a bin as a SYMLINK in
72
+ // node_modules/.bin/, so `npm create @homepages/workspace` invokes the link while
73
+ // import.meta.url is the real dist/index.js. Comparing them unresolved makes this
74
+ // guard silently false on the only path that matters — the command exits 0 having
75
+ // done nothing. src/bin-entry.test.ts runs the bin through a symlink to hold this.
76
+ const invokedAs = process.argv[1];
77
+ if (invokedAs && fileURLToPath(import.meta.url) === realpathSync(resolve(invokedAs))) {
78
+ process.exit(await main());
79
+ }
package/dist/stamp.js ADDED
@@ -0,0 +1,49 @@
1
+ // Twin of packages/template-kit/src/cli/new/emit.ts — keep behavior identical.
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+ const RENAME = {
5
+ "_gitignore": ".gitignore",
6
+ "_package.json": "package.json",
7
+ "_manifest.json": "manifest.json",
8
+ "_Renderer.tsx": "Renderer.tsx",
9
+ "_schema.ts": "schema.ts",
10
+ "_fill-spec.ts": "fill-spec.ts",
11
+ "_fixtures.ts": "fixtures.ts",
12
+ };
13
+ function substitute(body, tokens) {
14
+ return body.replace(/\{\{(\w+)\}\}/g, (_m, key) => {
15
+ const value = tokens[key];
16
+ if (value === undefined)
17
+ throw new Error(`scaffold: no value for token {{${key}}}`);
18
+ return value;
19
+ });
20
+ }
21
+ async function walk(dir) {
22
+ const out = [];
23
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
24
+ const full = join(dir, entry.name);
25
+ if (entry.isDirectory())
26
+ out.push(...(await walk(full)));
27
+ else
28
+ out.push(full);
29
+ }
30
+ return out;
31
+ }
32
+ /** Emit `srcDir` into `destDir`; returns dest-relative paths written. */
33
+ export async function stampTree(srcDir, destDir, tokens) {
34
+ const files = await walk(srcDir);
35
+ const written = [];
36
+ for (const file of files) {
37
+ const rel = relative(srcDir, file);
38
+ const parts = rel.split("/");
39
+ const base = parts[parts.length - 1];
40
+ parts[parts.length - 1] = RENAME[base] ?? base;
41
+ const destRel = parts.join("/");
42
+ const destPath = join(destDir, destRel);
43
+ await mkdir(join(destPath, ".."), { recursive: true });
44
+ const body = substitute(await readFile(file, "utf8"), tokens);
45
+ await writeFile(destPath, body, "utf8");
46
+ written.push(destRel);
47
+ }
48
+ return written;
49
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@homepages/create-workspace",
3
+ "version": "0.8.0",
4
+ "description": "Scaffold a HomePages template workspace.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20.0.0"
9
+ },
10
+ "bin": {
11
+ "create-workspace": "dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "scaffold"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "node scripts/build.mjs",
22
+ "prepack": "npm run build",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "node --import tsx --test 'src/**/*.test.ts'",
25
+ "check": "npm run typecheck && npm run build && npm run test"
26
+ },
27
+ "devDependencies": {
28
+ "tsx": "^4.19.2",
29
+ "typescript": "^5.7.2"
30
+ }
31
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Section, Slot } from "@homepages/template-kit";
4
+
5
+ import type { Props } from "./schema";
6
+
7
+ // {{SECTION_DISPLAY}} — a pure, server-rendered function of its props. Wrap
8
+ // every editable slot in <Slot id="…"> so the editor can resolve clicks. For
9
+ // interactivity, add a "use client" island file beside this one.
10
+ export default function {{SECTION_PASCAL}}Renderer({ slots, nav }: Props): ReactNode {
11
+ return (
12
+ <Section id={nav.selfAnchor} className="bg-background text-ink">
13
+ <div className="mx-auto max-w-3xl px-6 py-12">
14
+ <Slot id="headline" textLeaf>
15
+ <h2 className="text-2xl font-semibold">{slots.headline}</h2>
16
+ </Slot>
17
+ </div>
18
+ </Section>
19
+ );
20
+ }
@@ -0,0 +1,18 @@
1
+ import type { FillSpecShape } from "@homepages/template-kit";
2
+
3
+ // Decision ids must match the `produced_by` references in schema.ts.
4
+ export const fillSpec = {
5
+ reads: [],
6
+ decisions: [
7
+ {
8
+ id: "headline_text",
9
+ type: "text-block",
10
+ produces: ["headline"],
11
+ length_cap: 80,
12
+ structure: "single_sentence",
13
+ perspective: "third",
14
+ voice_prompt: "Write a short, confident heading for this section.",
15
+ content_prompt: "Anchor the section's purpose in one sentence.",
16
+ },
17
+ ],
18
+ } as const satisfies FillSpecShape;
@@ -0,0 +1,13 @@
1
+ import type { FixtureModule } from "@homepages/template-kit";
2
+
3
+ // Base content only. Nullability coverage is derived from schema.ts. Add a
4
+ // `states` entry for a designed content-length or cardinality branch.
5
+ const fixtures: FixtureModule = {
6
+ base: {
7
+ slots: {
8
+ headline: "A typical headline that reads well in a short line",
9
+ },
10
+ },
11
+ };
12
+
13
+ export default fixtures;
@@ -0,0 +1,24 @@
1
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
2
+
3
+ // Replace the slots/options with the real shape this section needs. Every
4
+ // editable slot declares either a `source` (baked from the property graph via
5
+ // `direct(...)`/`derived(...)`) or a `produced_by` naming a decision in
6
+ // fill-spec.ts.
7
+ export const schema = {
8
+ meta: {
9
+ displayName: "{{SECTION_DISPLAY}}",
10
+ },
11
+ slots: {
12
+ headline: {
13
+ type: "text",
14
+ size: "medium",
15
+ editable_by_user: true,
16
+ required: true,
17
+ produced_by: "headline_text",
18
+ display_name: "Headline",
19
+ },
20
+ },
21
+ } as const satisfies SectionSchemaShape;
22
+
23
+ // Renderer prop type — derived from `schema`; do not hand-write it.
24
+ export type Props = SectionProps<typeof schema>;
@@ -0,0 +1,9 @@
1
+ {
2
+ "key": "{{TEMPLATE_KEY}}",
3
+ "name": "{{TEMPLATE_NAME}}",
4
+ "deliverable_type": "single_property_website",
5
+ "sections": [
6
+ { "section": "hero", "instance_id": "hero-1", "options": {}, "locked": [], "required": true, "reorderable": false }
7
+ ],
8
+ "reconcile": []
9
+ }
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useState } from "react";
4
+
5
+ import type { IslandEditorOptions } from "@homepages/template-kit";
6
+
7
+ // A "use client" island: interactive, hydrated in the browser as its own React
8
+ // root. State, effects, and event handlers are fine here (they are NOT in a
9
+ // Renderer). Its props cross the server→browser boundary as JSON, so they must
10
+ // be strictly serializable — here, two strings and a number.
11
+ export default function ExpandableText({
12
+ text,
13
+ collapsedChars,
14
+ moreLabel,
15
+ }: {
16
+ text: string;
17
+ collapsedChars: number;
18
+ moreLabel: string;
19
+ }): ReactNode {
20
+ const [open, setOpen] = useState(false);
21
+ const isLong = text.length > collapsedChars;
22
+ const shown = open || !isLong ? text : `${text.slice(0, collapsedChars).trimEnd()}…`;
23
+ return (
24
+ <p className="text-base leading-relaxed text-ink/80">
25
+ {shown}
26
+ {isLong ? (
27
+ <button
28
+ type="button"
29
+ data-testid="expand-toggle"
30
+ className="ml-2 font-medium text-accent underline"
31
+ onClick={() => setOpen((v) => !v)}
32
+ >
33
+ {open ? "Show less" : moreLabel}
34
+ </button>
35
+ ) : null}
36
+ </p>
37
+ );
38
+ }
39
+
40
+ // Optional: how this island behaves inside the editor canvas. Omit `editor`
41
+ // entirely and the island renders as static markup in the editor (right for
42
+ // most). Here we hydrate it live so the toggle works while editing.
43
+ export const editor: IslandEditorOptions = {
44
+ live: true,
45
+ };
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Image, Section, Slot } from "@homepages/template-kit";
4
+
5
+ import { Feature } from "./components/Feature";
6
+ import ExpandableText from "./ExpandableText";
7
+ import type { Props } from "./schema";
8
+
9
+ // Hero — a pure server-rendered function of its props: no fetches, no globals,
10
+ // no state. Every editable slot's outer DOM is wrapped in <Slot id="…"> (or a
11
+ // primitive with slotId="…") so the editor can map a click back to the slot.
12
+ // The one interactive part is the <ExpandableText> island.
13
+ export default function HeroRenderer({ slots, options, nav }: Props): ReactNode {
14
+ const align = options.align === "left" ? "items-start text-left" : "items-center text-center";
15
+ return (
16
+ <Section id={nav.selfAnchor} className="bg-background text-ink">
17
+ <Image
18
+ slotId="hero_image"
19
+ src={slots.hero_image.url}
20
+ responsive={slots.hero_image.responsive}
21
+ alt={slots.hero_image.alt}
22
+ loading="eager"
23
+ className="w-full"
24
+ />
25
+ <div className={`mx-auto flex max-w-3xl flex-col gap-4 px-6 py-12 ${align}`}>
26
+ <Slot id="headline" textLeaf>
27
+ <h1 className="text-3xl font-semibold">{slots.headline}</h1>
28
+ </Slot>
29
+ <Slot id="blurb">
30
+ {/* The island receives JSON-serializable props only. */}
31
+ <ExpandableText text={slots.blurb} collapsedChars={140} moreLabel="Read more" />
32
+ </Slot>
33
+ <div className="mt-2 grid w-full grid-cols-2 gap-3">
34
+ <Feature label="Status" value="For sale" />
35
+ <Feature label="Type" value="Single-family" />
36
+ </div>
37
+ </div>
38
+ </Section>
39
+ );
40
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ // A nested server component. Proof that a section folder is free-form: only the
4
+ // four contract files (Renderer/schema/fill-spec/fixtures) are reserved — a
5
+ // components/ dir (or hooks/, utils/, any nesting) is yours to organize. This
6
+ // renders as static HTML and ships zero JavaScript.
7
+ export function Feature({ label, value }: { label: string; value: string }): ReactNode {
8
+ return (
9
+ <div className="rounded-lg border border-ink/10 p-4">
10
+ <div className="text-sm text-ink/60">{label}</div>
11
+ <div className="text-lg font-semibold text-ink">{value}</div>
12
+ </div>
13
+ );
14
+ }
@@ -0,0 +1,29 @@
1
+ import type { FillSpecShape } from "@homepages/template-kit";
2
+
3
+ // The fill spec drives the AI fill. `headline` is a direct-source slot
4
+ // (SOURCE_FIELDS "address") baked by the engine — it needs no decision here.
5
+ // `hero_image` and `blurb` each name a decision whose id matches their
6
+ // `produced_by` in schema.ts.
7
+ export const fillSpec = {
8
+ reads: [],
9
+ decisions: [
10
+ {
11
+ id: "hero_image_assign",
12
+ type: "image-assign",
13
+ produces: ["hero_image"],
14
+ tag_query: "category:exterior",
15
+ selection_prompt: "Pick a clear, well-lit exterior shot of the property.",
16
+ },
17
+ {
18
+ id: "blurb_text",
19
+ type: "text-block",
20
+ produces: ["blurb"],
21
+ length_cap: 400,
22
+ // `structure` enum is single_sentence | paragraph | bullet_list — not "single_paragraph".
23
+ structure: "paragraph",
24
+ perspective: "third",
25
+ voice_prompt: "Write a warm, concrete paragraph a buyer would want to read.",
26
+ content_prompt: "Describe what makes this home appealing in 2–4 sentences.",
27
+ },
28
+ ],
29
+ } as const satisfies FillSpecShape;
@@ -0,0 +1,41 @@
1
+ import type { FixtureModule } from "@homepages/template-kit";
2
+ import { luxuryMultiUnit, mkResponsive } from "@homepages/template-kit/fixtures";
3
+
4
+ // Base content the section is designed for. Nullability coverage (each optional
5
+ // slot rendered empty) is derived automatically from schema.ts — no fixture
6
+ // needed for it. Add a `states` entry only for a designed scenario that
7
+ // combines overrides at once (a content-length or option branch the section
8
+ // renders differently).
9
+ const heroImage = luxuryMultiUnit.photos[19]!; // exterior facade shot
10
+
11
+ const fixtures: FixtureModule = {
12
+ base: {
13
+ slots: {
14
+ headline: luxuryMultiUnit.facts.address.display,
15
+ hero_image: {
16
+ photo_id: heroImage.id,
17
+ url: heroImage.url,
18
+ alt: heroImage.alt,
19
+ // Height set to a 16:9 ratio to match the slot's locked crop.
20
+ responsive: mkResponsive(heroImage.url, 2048, 1152),
21
+ },
22
+ blurb: "A 12-unit mansard Victorian in Jamaica Plain, moments from the Emerald Necklace.",
23
+ },
24
+ options: { align: "center" },
25
+ },
26
+ states: {
27
+ // Left-aligned layout branch (option override).
28
+ left: { options: { align: "left" } },
29
+ // Long-blurb branch: exercises the island's expand/collapse path.
30
+ "long-blurb": {
31
+ slots: {
32
+ blurb:
33
+ "A 12-unit mansard Victorian in Jamaica Plain, with a terrazzo lobby, an " +
34
+ "on-site gym, quartz kitchens throughout, and a top-floor penthouse with a " +
35
+ "private rooftop terrace and skyline views — all moments from the Emerald Necklace.",
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export default fixtures;
@@ -0,0 +1,60 @@
1
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
2
+ import { direct } from "@homepages/template-kit";
3
+
4
+ // Hero — the starter section. It exercises the whole authoring contract in one
5
+ // place so the first section you read teaches the shape:
6
+ //
7
+ // Slots (editable content, declared here in the order the Renderer paints
8
+ // them — the sidebar-order check enforces that the two agree):
9
+ // hero_image image, AI-assigned from the property's photos; `crop` tells
10
+ // the editor's cropper to keep a locked 16:9 frame.
11
+ // headline text, fact-bound with direct("address") — seeded verbatim
12
+ // from the property, no model call.
13
+ // blurb text, AI-written; the island below makes it expandable.
14
+ //
15
+ // Options (behavioral, not editable content):
16
+ // align where the text column sits — "center" (default) or "left".
17
+ //
18
+ // Every editable slot either declares a `source` (baked from the property
19
+ // graph) or a `produced_by` that names a decision in fill-spec.ts.
20
+ export const schema = {
21
+ meta: {
22
+ displayName: "Hero",
23
+ },
24
+ slots: {
25
+ hero_image: {
26
+ type: "image",
27
+ editable_by_user: true,
28
+ required: true,
29
+ produced_by: "hero_image_assign",
30
+ display_name: "Hero image",
31
+ // The editor's cropper keeps a locked 16:9 frame; the pill reads "16:9".
32
+ crop: { mode: "locked", aspect: 16 / 9, aspectLabel: "16:9" },
33
+ },
34
+ headline: {
35
+ type: "text",
36
+ size: "medium",
37
+ editable_by_user: true,
38
+ required: true,
39
+ source: direct("address"),
40
+ display_name: "Headline",
41
+ },
42
+ blurb: {
43
+ type: "text",
44
+ size: "long",
45
+ editable_by_user: true,
46
+ required: true,
47
+ produced_by: "blurb_text",
48
+ display_name: "Blurb",
49
+ },
50
+ },
51
+ options: {
52
+ // An enum option: its value type is the union of `values`. Read it in the
53
+ // Renderer as `options.align` — never hand-write the props type.
54
+ align: { type: "enum", values: ["center", "left"], default: "center" },
55
+ },
56
+ } as const satisfies SectionSchemaShape;
57
+
58
+ // Renderer prop type — derived from `schema`. Do not hand-write a separate type;
59
+ // the props-from-schema lint rule enforces this.
60
+ export type Props = SectionProps<typeof schema>;
@@ -0,0 +1,24 @@
1
+ /* Generated by `template-kit theme` from theme.ts — do not edit.
2
+ Re-run `template-kit theme` after changing theme.ts. */
3
+ @theme inline {
4
+ --color-accent: var(--tr-color-accent);
5
+ --color-background: var(--tr-color-background);
6
+ --color-ink: var(--tr-color-ink);
7
+ --font-body: var(--tr-font-body);
8
+ --text-base: var(--tr-text-base);
9
+ }
10
+
11
+ :root {
12
+ --tr-color-accent: #2f6df6;
13
+ --tr-color-background: #ffffff;
14
+ --tr-color-ink: #1a1d21;
15
+ --tr-font-body: system-ui, -apple-system, sans-serif;
16
+ --tr-text-base: 1rem;
17
+ }
18
+
19
+ body {
20
+ font-family: var(--tr-font-body);
21
+ color: var(--tr-color-ink);
22
+ background: var(--tr-color-background);
23
+ font-size: var(--tr-text-base);
24
+ }
@@ -0,0 +1,27 @@
1
+ import type { TokenTheme } from "@homepages/template-kit";
2
+
3
+ // A template's design tokens. `template-kit theme` compiles this to theme.css;
4
+ // `template-kit dev` reads it directly. The `document` refs must name declared
5
+ // tokens (color/background → colors, font → fonts, fontSize → type).
6
+ export const theme: TokenTheme = {
7
+ colors: {
8
+ ink: "#1a1d21",
9
+ background: "#ffffff",
10
+ accent: "#2f6df6",
11
+ },
12
+ fonts: {
13
+ body: "system-ui, -apple-system, sans-serif",
14
+ },
15
+ type: {
16
+ base: "1rem",
17
+ },
18
+ radii: {},
19
+ shadows: {},
20
+ layout: {},
21
+ document: {
22
+ font: "body",
23
+ color: "ink",
24
+ background: "background",
25
+ fontSize: "base",
26
+ },
27
+ };
@@ -0,0 +1,45 @@
1
+ <!-- Tool-agnostic mirror of CLAUDE.md — keep the two in sync. -->
2
+
3
+ # {{WORKSPACE_NAME}} — agent guide
4
+
5
+ You are authoring HomePages marketing-section templates in this workspace.
6
+
7
+ ## First 30 seconds
8
+
9
+ - **Templates live in `templates/`.** Each is a plain folder — not an npm package.
10
+ Add one with `npx template-kit new <name>`; add a section with
11
+ `npx template-kit new-section <name>`.
12
+ - **One kit, one version.** `@homepages/template-kit` is the only toolchain
13
+ dependency; every template shares it. Do not add per-template `package.json`,
14
+ `tsconfig`, or `eslint` files — there is exactly one of each, at this root.
15
+ - **The docs corpus is your whole universe:** `node_modules/@homepages/template-kit/guide/`
16
+ (`llms.txt` is the router). Read it before authoring; do not guess the contract.
17
+
18
+ ## The section contract (reserved files)
19
+
20
+ A section is `templates/<t>/sections/<name>/` with four reserved files:
21
+ `Renderer.tsx` (server component — pure, ships no JS), `schema.ts` (slots +
22
+ options), `fill-spec.ts` (the AI fill spec), `fixtures.ts` (preview content).
23
+ Plus template-level `manifest.json` (reserved) and `theme.ts` → `theme.css`
24
+ (generated). Everything else in a section folder — `components/`, hooks, utils,
25
+ islands — is yours to organize.
26
+
27
+ ## Server vs. client
28
+
29
+ - A `Renderer.tsx` (and any component it imports without `"use client"`) is a
30
+ **server component**: a pure function of props. No state, effects, event
31
+ handlers, browser APIs, `Date.now()`, or randomness.
32
+ - For interactivity, write a **`"use client"` island**: an ordinary React
33
+ component marked `"use client"`, default-exported, one per file. State,
34
+ effects, and handlers are fine. Its props from the Renderer must be
35
+ JSON-serializable (no functions/Dates/class instances).
36
+
37
+ ## Command loop
38
+
39
+ - `npm run dev [-- <template>]` — live preview (islands hydrate)
40
+ - `npm run check [-- <template>|-- --all]` — typecheck + lint + validate
41
+ - `npm run pack -- <template>` — build the submission zip
42
+ - `npx template-kit theme <template>` — regenerate `theme.css` after editing tokens
43
+
44
+ Read the starter section (`templates/starter/sections/hero/`) — it is a working,
45
+ annotated example of the whole contract.
@@ -0,0 +1,43 @@
1
+ # {{WORKSPACE_NAME}} — agent guide
2
+
3
+ You are authoring HomePages marketing-section templates in this workspace.
4
+
5
+ ## First 30 seconds
6
+
7
+ - **Templates live in `templates/`.** Each is a plain folder — not an npm package.
8
+ Add one with `npx template-kit new <name>`; add a section with
9
+ `npx template-kit new-section <name>`.
10
+ - **One kit, one version.** `@homepages/template-kit` is the only toolchain
11
+ dependency; every template shares it. Do not add per-template `package.json`,
12
+ `tsconfig`, or `eslint` files — there is exactly one of each, at this root.
13
+ - **The docs corpus is your whole universe:** `node_modules/@homepages/template-kit/guide/`
14
+ (`llms.txt` is the router). Read it before authoring; do not guess the contract.
15
+
16
+ ## The section contract (reserved files)
17
+
18
+ A section is `templates/<t>/sections/<name>/` with four reserved files:
19
+ `Renderer.tsx` (server component — pure, ships no JS), `schema.ts` (slots +
20
+ options), `fill-spec.ts` (the AI fill spec), `fixtures.ts` (preview content).
21
+ Plus template-level `manifest.json` (reserved) and `theme.ts` → `theme.css`
22
+ (generated). Everything else in a section folder — `components/`, hooks, utils,
23
+ islands — is yours to organize.
24
+
25
+ ## Server vs. client
26
+
27
+ - A `Renderer.tsx` (and any component it imports without `"use client"`) is a
28
+ **server component**: a pure function of props. No state, effects, event
29
+ handlers, browser APIs, `Date.now()`, or randomness.
30
+ - For interactivity, write a **`"use client"` island**: an ordinary React
31
+ component marked `"use client"`, default-exported, one per file. State,
32
+ effects, and handlers are fine. Its props from the Renderer must be
33
+ JSON-serializable (no functions/Dates/class instances).
34
+
35
+ ## Command loop
36
+
37
+ - `npm run dev [-- <template>]` — live preview (islands hydrate)
38
+ - `npm run check [-- <template>|-- --all]` — typecheck + lint + validate
39
+ - `npm run pack -- <template>` — build the submission zip
40
+ - `npx template-kit theme <template>` — regenerate `theme.css` after editing tokens
41
+
42
+ Read the starter section (`templates/starter/sections/hero/`) — it is a working,
43
+ annotated example of the whole contract.
@@ -0,0 +1,40 @@
1
+ # {{WORKSPACE_NAME}}
2
+
3
+ A HomePages template workspace. Templates live in `templates/`. The kit toolchain
4
+ is one dependency (`@homepages/template-kit`) shared by every template here.
5
+
6
+ ## Commands
7
+
8
+ - `npm run dev` — preview a template (`npm run dev -- <template>` to pick one)
9
+ - `npm run check` — typecheck, lint, and validate (`-- --all` for every template)
10
+ - `npm run pack` — build a template's submission zip (`npm run pack -- <template>`)
11
+ - `npx template-kit new <name>` — add another template
12
+ - `npx template-kit new-section <name>` — add a section to a template
13
+ - `npx template-kit theme <template>` — regenerate `theme.css` after editing `theme.ts`
14
+
15
+ ## Project structure
16
+
17
+ Like a Next.js `app/`, `templates/` holds your authored units. A **section** is a
18
+ folder of reserved "special files" plus anything else you want beside them.
19
+
20
+ ```
21
+ templates/<template>/
22
+ manifest.json # reserved — composition of section instances
23
+ theme.ts # reserved — design tokens
24
+ theme.css # generated from theme.ts (do not edit)
25
+ sections/<name>/
26
+ Renderer.tsx # reserved — server component (ships no JS)
27
+ schema.ts # reserved — slots + options
28
+ fill-spec.ts # reserved — the AI fill spec
29
+ fixtures.ts # reserved — preview content
30
+ components/ # yours — free-form colocated components
31
+ <Anything>.tsx # yours — a "use client" island, utils, hooks, …
32
+ ```
33
+
34
+ **Reserved** files are the contract `check` enforces. **Everything else is yours** —
35
+ add colocated components, your own `docs/`, extra tooling, CI, more npm scripts.
36
+
37
+ ## Docs
38
+
39
+ The full authoring guide ships inside the kit:
40
+ `node_modules/@homepages/template-kit/guide/` (start at `INDEX.md` / `llms.txt`).
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ .tk-dev-cache/
3
+ templates/**/theme.css.map
4
+ *.log
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "{{WORKSPACE_NAME}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "template-kit dev",
7
+ "check": "template-kit check",
8
+ "pack": "template-kit pack"
9
+ },
10
+ "dependencies": {
11
+ "@homepages/template-kit": "^{{KIT_VERSION}}",
12
+ "react": "^19.0.0",
13
+ "react-dom": "^19.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@tailwindcss/cli": "^4.3.0",
17
+ "@types/react": "^19.0.0",
18
+ "@types/react-dom": "^19.0.0",
19
+ "@typescript-eslint/parser": "^8.60.1",
20
+ "@vitejs/plugin-react": "^4.3.4",
21
+ "esbuild": "^0.28.1",
22
+ "eslint": "^9.39.4",
23
+ "tailwindcss": "^4.3.0",
24
+ "typescript": "^5.7.2",
25
+ "vite": "^6.0.5"
26
+ }
27
+ }
@@ -0,0 +1,3 @@
1
+ import kit from "@homepages/template-kit/eslint";
2
+
3
+ export default kit;
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "@homepages/template-kit/tsconfig",
3
+ "include": ["templates/**/*.ts", "templates/**/*.tsx"]
4
+ }