@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 +79 -0
- package/dist/stamp.js +49 -0
- package/package.json +31 -0
- package/scaffold/section/_Renderer.tsx +20 -0
- package/scaffold/section/_fill-spec.ts +18 -0
- package/scaffold/section/_fixtures.ts +13 -0
- package/scaffold/section/_schema.ts +24 -0
- package/scaffold/template/_manifest.json +9 -0
- package/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
- package/scaffold/template/sections/hero/Renderer.tsx +40 -0
- package/scaffold/template/sections/hero/components/Feature.tsx +14 -0
- package/scaffold/template/sections/hero/fill-spec.ts +29 -0
- package/scaffold/template/sections/hero/fixtures.ts +41 -0
- package/scaffold/template/sections/hero/schema.ts +60 -0
- package/scaffold/template/theme.css +24 -0
- package/scaffold/template/theme.ts +27 -0
- package/scaffold/workspace/AGENTS.md +45 -0
- package/scaffold/workspace/CLAUDE.md +43 -0
- package/scaffold/workspace/README.md +40 -0
- package/scaffold/workspace/_gitignore +4 -0
- package/scaffold/workspace/_package.json +27 -0
- package/scaffold/workspace/eslint.config.mjs +3 -0
- package/scaffold/workspace/tsconfig.json +4 -0
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,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
|
+
}
|