@homepages/template-kit 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +39 -4
  3. package/dist/asset-modules.d.ts +81 -0
  4. package/dist/cli/check/config.js +5 -1
  5. package/dist/cli/check/css.js +27 -13
  6. package/dist/cli/check/loader.js +4 -0
  7. package/dist/cli/check/section-assets.js +58 -0
  8. package/dist/cli/check/stages/size.js +124 -8
  9. package/dist/cli/check/stages/tree.js +26 -27
  10. package/dist/cli/dev/build-css.js +91 -0
  11. package/dist/cli/dev/catalog.js +35 -0
  12. package/dist/cli/dev/compose-template.js +38 -0
  13. package/dist/cli/dev/content-override.js +58 -0
  14. package/dist/cli/dev/discover.js +35 -0
  15. package/dist/cli/dev/fill-state.js +75 -0
  16. package/dist/cli/dev/index.js +32 -0
  17. package/dist/cli/dev/inspect.js +38 -0
  18. package/dist/cli/dev/island-bootstrap.js +38 -0
  19. package/dist/cli/dev/island-map.js +35 -0
  20. package/dist/cli/dev/island-transform.js +31 -0
  21. package/dist/cli/dev/manifest-instances.js +31 -0
  22. package/dist/cli/dev/render-section.js +24 -0
  23. package/dist/cli/dev/screenshot-target.js +24 -0
  24. package/dist/cli/dev/section-page.js +54 -0
  25. package/dist/cli/dev/server.js +480 -0
  26. package/dist/cli/dev/slot-schema.js +12 -0
  27. package/dist/cli/dev/structure-summary.js +118 -0
  28. package/dist/cli/dev/tailwind.js +32 -0
  29. package/dist/cli/dev/vite-server.js +33 -0
  30. package/dist/cli/dev/workspace.js +63 -0
  31. package/dist/cli/link/index.js +74 -0
  32. package/dist/cli/link/overlay.js +59 -0
  33. package/dist/cli/link/watch.js +25 -0
  34. package/dist/cli/new/emit.js +48 -0
  35. package/dist/cli/new/index.js +69 -0
  36. package/dist/cli/new/scaffold/section/_Renderer.tsx +20 -0
  37. package/dist/cli/new/scaffold/section/_fill-spec.ts +18 -0
  38. package/dist/cli/new/scaffold/section/_fixtures.ts +13 -0
  39. package/dist/cli/new/scaffold/section/_schema.ts +24 -0
  40. package/dist/cli/new/scaffold/template/_manifest.json +9 -0
  41. package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
  42. package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +40 -0
  43. package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +14 -0
  44. package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +29 -0
  45. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +41 -0
  46. package/dist/cli/new/scaffold/template/sections/hero/schema.ts +60 -0
  47. package/dist/cli/new/scaffold/template/theme.css +24 -0
  48. package/dist/cli/new/scaffold/template/theme.ts +27 -0
  49. package/dist/cli/new/scaffold-assets.js +20 -0
  50. package/dist/cli/pack/collect.js +44 -0
  51. package/dist/cli/pack/guards.js +57 -0
  52. package/dist/cli/pack/index.js +66 -0
  53. package/dist/cli/pack/manifest.js +15 -0
  54. package/dist/cli/pack/zip.js +86 -0
  55. package/dist/cli.js +80 -14
  56. package/dist/dev-client/assets/index-DfEfrp6P.css +1 -0
  57. package/dist/dev-client/assets/index-bpMP0b0W.js +50 -0
  58. package/dist/dev-client/index.html +13 -0
  59. package/dist/eslint/is-client-file.d.ts +4 -0
  60. package/dist/index.d.ts +1 -0
  61. package/dist/islands/detect.d.ts +16 -0
  62. package/dist/islands/detect.js +22 -0
  63. package/dist/islands/discover.d.ts +5 -0
  64. package/dist/islands/discover.js +19 -0
  65. package/dist/islands/esbuild-plugin.d.ts +13 -0
  66. package/dist/islands/esbuild-plugin.js +39 -0
  67. package/dist/islands/wrap.d.ts +12 -0
  68. package/dist/islands/wrap.js +48 -0
  69. package/dist/package.js +1 -1
  70. package/dist/ssr.d.ts +31 -0
  71. package/dist/ssr.js +46 -0
  72. package/docs/INDEX.md +9 -1
  73. package/docs/assets.md +128 -0
  74. package/docs/check.md +6 -3
  75. package/docs/dev.md +187 -0
  76. package/docs/islands.md +5 -2
  77. package/docs/llms.txt +41 -3
  78. package/docs/new.md +27 -0
  79. package/docs/overview.md +57 -0
  80. package/docs/pack.md +46 -0
  81. package/docs/primitives.md +5 -6
  82. package/docs/quickstart.md +102 -0
  83. package/docs/recipes/INDEX.md +27 -0
  84. package/docs/recipes/bind-property-fact.md +58 -0
  85. package/docs/recipes/collection-slot.md +100 -0
  86. package/docs/recipes/fill-spec-decision.md +66 -0
  87. package/docs/recipes/fixture-states.md +68 -0
  88. package/docs/recipes/image-slot-crop.md +98 -0
  89. package/docs/recipes/interactive-island.md +100 -0
  90. package/docs/recipes/organize-section-folder.md +72 -0
  91. package/docs/recipes/prepare-submission.md +55 -0
  92. package/docs/recipes/second-template.md +49 -0
  93. package/docs/recipes/select-slot.md +59 -0
  94. package/docs/recipes/static-asset.md +103 -0
  95. package/docs/recipes/third-party-package.md +90 -0
  96. package/docs/rules/INDEX.md +4 -2
  97. package/docs/rules/bundle-binary-asset.md +33 -52
  98. package/docs/rules/bundle-incomplete.md +2 -2
  99. package/docs/rules/css-reason.md +3 -3
  100. package/docs/rules/no-bare-css-import.md +8 -8
  101. package/docs/rules/no-css-import-from-render-path.md +7 -8
  102. package/docs/rules/size-assets.md +88 -0
  103. package/docs/rules/size-island-bundle.md +123 -0
  104. package/docs/rules/size-section-css.md +19 -14
  105. package/docs/schema-system.md +28 -28
  106. package/docs/theme-and-css.md +2 -2
  107. package/docs/vocabulary.md +98 -0
  108. package/package.json +12 -3
  109. package/tsconfig.json +1 -1
@@ -0,0 +1,123 @@
1
+ ---
2
+ purpose: The template-kit/size-island-bundle authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, size-renderer-bundle.md, size-section-css.md, server-vs-client.md]
5
+ updated: 2026-07-15
6
+ ---
7
+ # template-kit/size-island-bundle
8
+
9
+ > Each island's browser bundle must be **≤ 50 KB gzip**. The budget is not
10
+ > author-overridable, and it applies per island, not per section.
11
+
12
+ ## Rule
13
+
14
+ An island — a component whose file opens with the `"use client"` directive (see
15
+ [Server vs client](server-vs-client.md)) — ships to the browser as its own bundle,
16
+ separate from the section's server-rendered HTML. Each island is bundled with esbuild
17
+ and minified, with **React treated as external** (the page's runtime chunk supplies it
18
+ once, shared by every island on the page, and is not counted against any one of them).
19
+ The gzipped result must be **50 KB or less**.
20
+
21
+ A section can have more than one island. The budget is checked **per island**: a section
22
+ with three islands each at 40 KB passes; a section with one island at 60 KB fails, even
23
+ though the same section's other files are small.
24
+
25
+ The budget is **visible in `template-kit check`'s configuration and is not
26
+ author-overridable**. A template workspace cannot raise it, and there is no per-island
27
+ exemption.
28
+
29
+ ## Reason
30
+
31
+ An island is the part of the page that has to work on a phone before the visitor does
32
+ anything — a carousel, a filter, a form. Every one of its bytes is downloaded, parsed,
33
+ and executed on the client, on top of whatever the page's shared runtime already costs.
34
+ Unlike the section's server-rendered markup, which the browser only has to paint, an
35
+ island's code has to run.
36
+
37
+ The number is set to be generous, so it does not fight ordinary authoring — a real
38
+ interactive widget fits comfortably inside it. You hit the budget when something
39
+ **runaway** gets pulled into the client bundle:
40
+
41
+ - **a whole UI or icon library imported for one component**, which drags every export
42
+ into the bundle so the island can use a handful of them;
43
+ - **a heavy dependency that belongs on the server**, ported into the island because it
44
+ was easiest to reach for where the code was being written, not because the browser
45
+ needs it.
46
+
47
+ That is the shape of essentially every violation: not an island that grew, but a single
48
+ import that was more expensive than it looked.
49
+
50
+ ## Fix
51
+
52
+ Find the import that is not paying for itself, or narrow what ships to the browser at
53
+ all.
54
+
55
+ - **Import named symbols, never a namespace.** `import { ArrowRight } from "…"` is
56
+ tree-shakeable; `import * as Icons from "…"` is not — a namespace object has to contain
57
+ everything, so nothing can be dropped.
58
+ - **Prefer the package's narrow entry** if it offers one, instead of its default
59
+ "everything" bundle.
60
+ - **Move what doesn't need to be interactive out of the island.** If only a small part of
61
+ a component actually needs client-side behavior — an expand/collapse toggle inside an
62
+ otherwise static block — split the interactive part into its own small island and leave
63
+ the rest server-rendered. Server-rendered markup costs nothing against this budget.
64
+ - **Hand-write the residue.** If you use one function out of a large utility package, copy
65
+ the function instead of importing the package for it.
66
+
67
+ ### Before
68
+
69
+ ```tsx
70
+ // sections/gallery/Carousel.tsx
71
+ "use client";
72
+
73
+ import * as Icons from "lucide-react"; // ← the whole set: every icon, in this island's bundle
74
+ import { useState } from "react";
75
+
76
+ export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
77
+ const [index, setIndex] = useState(0);
78
+ return (
79
+ <div>
80
+ <img src={slides[index]?.url} alt="" />
81
+ <button onClick={() => setIndex((i) => (i - 1 + slides.length) % slides.length)}>
82
+ <Icons.ChevronLeft aria-hidden />
83
+ </button>
84
+ <button onClick={() => setIndex((i) => (i + 1) % slides.length)}>
85
+ <Icons.ChevronRight aria-hidden />
86
+ </button>
87
+ </div>
88
+ );
89
+ }
90
+ ```
91
+
92
+ ### After
93
+
94
+ ```tsx
95
+ // sections/gallery/Carousel.tsx
96
+ "use client";
97
+
98
+ import { ChevronLeft, ChevronRight } from "lucide-react"; // ← two icons, and the bundler can drop the rest
99
+ import { useState } from "react";
100
+
101
+ export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
102
+ const [index, setIndex] = useState(0);
103
+ return (
104
+ <div>
105
+ <img src={slides[index]?.url} alt="" />
106
+ <button onClick={() => setIndex((i) => (i - 1 + slides.length) % slides.length)}>
107
+ <ChevronLeft aria-hidden />
108
+ </button>
109
+ <button onClick={() => setIndex((i) => (i + 1) % slides.length)}>
110
+ <ChevronRight aria-hidden />
111
+ </button>
112
+ </div>
113
+ );
114
+ }
115
+ ```
116
+
117
+ ## See also
118
+
119
+ - [`size-renderer-bundle`](size-renderer-bundle.md) — the same budget on the section's
120
+ server-rendered bundle.
121
+ - [`size-section-css`](size-section-css.md) — the same budget on the section's stylesheet.
122
+ - [Server vs client](server-vs-client.md) — what makes a file an island in the first
123
+ place.
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  purpose: The template-kit/size-section-css authoring rule — what it enforces, why, and how to fix it.
3
3
  status: living
4
- related: [INDEX.md, size-renderer-bundle.md, ../theme-and-css.md]
5
- updated: 2026-07-14
4
+ related: [INDEX.md, size-renderer-bundle.md, size-assets.md, ../theme-and-css.md]
5
+ updated: 2026-07-15
6
6
  ---
7
7
  # template-kit/size-section-css
8
8
 
@@ -16,7 +16,7 @@ everything that lands in the layer:
16
16
 
17
17
  - the section's hand-written CSS, and
18
18
  - **every package stylesheet its module graph imports** — the `import "swiper/css"` in a
19
- component, an island, or a marked enhancer.
19
+ component or an island.
20
20
 
21
21
  The budget is **visible in `template-kit check`'s configuration and is not
22
22
  author-overridable**. A workspace cannot raise it, and there is no per-section exemption.
@@ -29,14 +29,15 @@ CSS blocks the first paint.
29
29
 
30
30
  "Including package CSS" is not a technicality; it is the failure this rule exists to catch.
31
31
  Your own stylesheet is almost never the problem — it is a few kilobytes of overrides. The
32
- package's is. And the reason it is invisible to you is the mechanism: a package stylesheet's
33
- `url()` references its icon sprites, its webfont are **inlined as `data:` URIs** into the
34
- compiled stylesheet. A 300 KB font file arrives in your published CSS without you ever
35
- having seen a 300 KB file. Nothing in your source looks large. Nothing in the package's
36
- `dist` listing looks alarming. The compiled layer is 400 KB.
32
+ package's is. A one-line `import "some-ui/dist.css"` can drag in thousands of rules for
33
+ components you never render resets, theme variants, every widget's styles and they all
34
+ land in your compiled layer. Nothing in your source looks large; the compiled layer is
35
+ 400 KB.
37
36
 
38
37
  So the number this check reports is frequently the first time anyone finds out what a
39
- one-line CSS import actually cost.
38
+ one-line CSS import actually cost. (What this budget does **not** count is asset bytes: a
39
+ `url()`-referenced webfont or icon file resolves to a cached URL and is weighed separately —
40
+ see [`size-assets`](size-assets.md).)
40
41
 
41
42
  ## Fix
42
43
 
@@ -45,10 +46,11 @@ Three moves, in order of how often they work:
45
46
  1. **Import the slimmest entry the package offers.** Most CSS-shipping packages have a core
46
47
  stylesheet plus opt-in modules, *and* a fat "everything" bundle that is the one you get
47
48
  from copying the quickstart. Take the core, plus only the modules you use.
48
- 2. **Cut the webfont.** If the package's stylesheet drags in a font for its icons, and you
49
- are using three of those icons, replace them with inline SVG — or with a family the
50
- template's theme already declares in `fonts` (`--tr-font-<name>`), which costs nothing
51
- because it is already loaded.
49
+ 2. **Drop the icon font.** If the package's stylesheet declares an icon font and you use
50
+ three of its glyphs, replace them with inline SVG — or with a family the template's theme
51
+ already declares in `fonts` (`--tr-font-<name>`), which costs nothing because it is
52
+ already loaded. (The font *file* is weighed under [`size-assets`](size-assets.md), but its
53
+ `@font-face` rules and every `.icon-*` class still land in this layer.)
52
54
  3. **Drop the package and hand-write the residue.** If you use one component from a large
53
55
  widget library, its stylesheet is paying for components you never render.
54
56
 
@@ -83,12 +85,15 @@ export default function Carousel({ slides }: { slides: { id: string; url: string
83
85
  ```
84
86
 
85
87
  The arrows and dots that came from the bundle's icon font are drawn as inline SVG in the
86
- component instead, so nothing pulls a webfont into the layer.
88
+ component instead, so the layer no longer carries that font's `@font-face` and icon-class
89
+ rules (and the font file no longer counts against [`size-assets`](size-assets.md) either).
87
90
 
88
91
  ## See also
89
92
 
90
93
  - [`size-renderer-bundle`](size-renderer-bundle.md) — the same budget on the section's
91
94
  JavaScript.
95
+ - [`size-assets`](size-assets.md) — the separate budget on the static asset *files*
96
+ (fonts, images) a stylesheet or component references.
92
97
  - [`no-bare-css-import`](no-bare-css-import.md) — why a package's stylesheet is imported from
93
98
  code and never `@import`ed from CSS.
94
99
  - [Theme and CSS](../theme-and-css.md) — the fonts and tokens the page already loads, which
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  purpose: The section authoring contract — schema.ts, fill-spec.ts, fixtures.ts, and the closed source/transform vocabulary.
3
3
  status: living
4
- related: [primitives.md, theme-and-css.md]
5
- updated: 2026-07-14
4
+ related: [primitives.md, theme-and-css.md, vocabulary.md]
5
+ updated: 2026-07-16
6
6
  ---
7
7
  # The schema system
8
8
 
@@ -32,22 +32,17 @@ from. Without it, `SectionProps` cannot tell a required slot from an optional on
32
32
  and your renderer props collapse to a useless widened shape.
33
33
 
34
34
  ```ts
35
- import { direct, type FrameConfig, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
36
-
37
- export const HERO_FRAME = {
38
- breakpoint: 425,
39
- desktop: { aspect: 5 / 3 },
40
- } as const satisfies FrameConfig;
35
+ import { direct, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
41
36
 
42
37
  export const schema = {
43
38
  meta: { displayName: "Hero" },
44
39
  slots: {
45
- headline: { type: "text", size: "medium", required: true, produced_by: "headline_text" },
46
- listing_status: { type: "select", required: false, source: direct("listing_intent") },
47
- hero_image: { type: "image", required: true, produced_by: "hero_image_assign", frame: HERO_FRAME },
40
+ hero_image: { type: "image", required: true, produced_by: "hero_image_assign", crop: { mode: "locked", aspect: 16 / 9 } },
41
+ headline: { type: "text", size: "medium", required: true, source: direct("address") },
42
+ blurb: { type: "text", size: "long", required: true, produced_by: "blurb_text" },
48
43
  },
49
44
  options: {
50
- align: { type: "enum", values: ["left", "center"], default: "left" },
45
+ align: { type: "enum", values: ["left", "center"], default: "center" },
51
46
  },
52
47
  } as const satisfies SectionSchemaShape;
53
48
 
@@ -65,14 +60,20 @@ binds it to a computed transform. Both take names from a **closed vocabulary**,
65
60
  a typo is a compile error at the call site — not a runtime surprise:
66
61
 
67
62
  ```ts
68
- source: direct("listing_intent") // ✅
69
- source: direct("listing_intentt") // ✗ compile error: not a SOURCE_FIELDS key
63
+ source: direct("address") // ✅
64
+ source: direct("addresss") // ✗ compile error: not a SOURCE_FIELDS key
70
65
  ```
71
66
 
72
- The vocabulary is the kit's, not yours. Read it off the exported tables
73
- `SOURCE_FIELDS` (facts `direct()` accepts), `TRANSFORMS` (`derived()`), and
74
- `SLOT_TYPE_DESCRIPTORS` (the slot types) or let your editor autocomplete it.
75
- Adding to it is a kit release, not a change you can make in a template.
67
+ The vocabulary is the kit's, not yours. Adding to it is a kit release, not a
68
+ change you can make in a template. See [the closed vocabulary](vocabulary.md)
69
+ for the sets and when to pick each; your editor autocompletes the exact fields.
70
+
71
+ A `select` slot binds the same way as any other type — here fact-bound rather
72
+ than AI-produced, with `values` declaring the closed set the fact resolves to:
73
+
74
+ ```ts
75
+ listing_status: { type: "select", required: false, source: direct("listing_intent"), values: ["for_sale", "for_rent"] },
76
+ ```
76
77
 
77
78
  ### Grouping slots into one editor card
78
79
 
@@ -104,7 +105,6 @@ import type { Props } from "./schema.js";
104
105
 
105
106
  export default function Hero({ slots, options, nav }: Props) {
106
107
  // headline is `string` — the schema says required: true
107
- // listing_status is `string | null` — required: false
108
108
  // options.align is `"left" | "center"` — the enum's declared values
109
109
  return (
110
110
  <Section id={nav.selfAnchor}>
@@ -153,12 +153,12 @@ export const fillSpec = {
153
153
  decisions: [
154
154
  { id: "hero_image_assign", type: "image-assign", produces: ["hero_image"], tag_query: "category:exterior" },
155
155
  {
156
- id: "headline_text",
156
+ id: "blurb_text",
157
157
  type: "text-block",
158
- produces: ["headline"],
159
- length_cap: 60,
160
- structure: "single_sentence",
161
- voice_prompt: "Confident premium-listing voice. Title Case.",
158
+ produces: ["blurb"],
159
+ length_cap: 400,
160
+ structure: "paragraph",
161
+ voice_prompt: "Warm, concrete paragraph a buyer would want to read.",
162
162
  },
163
163
  ],
164
164
  } as const satisfies FillSpecShape;
@@ -177,14 +177,14 @@ import type { FixtureModule } from "@homepages/template-kit";
177
177
  const fixtures: FixtureModule = {
178
178
  base: {
179
179
  slots: {
180
- headline: "A mansard Victorian in the heart of Jamaica Plain",
181
- listing_status: "for_sale",
180
+ headline: "101 Queensway, Jamaica Plain, MA 02130",
182
181
  hero_image: { photo_id: "p1", url: "https://example.com/a.jpg", alt: "Front elevation" },
182
+ blurb: "A bright corner home moments from the Emerald Necklace.",
183
183
  },
184
- options: { align: "left" },
184
+ options: { align: "center" },
185
185
  },
186
186
  states: {
187
- centered: { label: "Centered", options: { align: "center" } },
187
+ left: { label: "Left-aligned", options: { align: "left" } },
188
188
  },
189
189
  };
190
190
 
@@ -177,8 +177,8 @@ never write an import to make either happen:
177
177
  template's own hand-written stylesheet is collected the same way, into
178
178
  `@layer template`.
179
179
  2. **Package CSS, harvested from the render path.** The build also bundles
180
- `Renderer.tsx`'s module graph — and any component that carries `export const
181
- enhancer` — and pulls in whatever `.css` those imports resolve to. A bare specifier
180
+ `Renderer.tsx`'s module graph — every component and island it imports and pulls in
181
+ whatever `.css` those imports resolve to. A bare specifier
182
182
  (`import "swiper/css";`) resolves into `node_modules` and lands in the same
183
183
  `@layer sections`; that is the sanctioned way for a third-party package's
184
184
  stylesheet to reach the page.
@@ -0,0 +1,98 @@
1
+ ---
2
+ purpose: The closed authoring vocabulary — slot types, direct() sources, derived() transforms, with selection semantics.
3
+ status: living
4
+ related: [schema-system.md]
5
+ updated: 2026-07-15
6
+ ---
7
+ # The closed authoring vocabulary
8
+
9
+ Three closed sets. A slot's `type` picks its runtime value shape; a slot's
10
+ `source` (`direct()` or `derived()`) picks which fact or computation fills it.
11
+ This page is a selection reference — *which one to pick* — not a restatement
12
+ of their field shapes.
13
+
14
+ ## Slot types
15
+
16
+ | type | runtime value shape | pick it when |
17
+ |---|---|---|
18
+ | `text` | `string` (`string \| null` if optional) | one editable line/paragraph (`size` short\|medium\|long) |
19
+ | `number` | `number` (`number \| null` if optional) | a numeric fact with formatting |
20
+ | `select` | `string` (`string \| null` if optional) | a closed set of choices the user picks from |
21
+ | `image` | `ImageValue` (`ImageValue \| null` if optional) | one framed photo (add `crop`/`frame`) |
22
+ | `image_collection` | `ImageCollectionValue` | a gallery of framed photos |
23
+ | `list` | `string[]` for a `text` element, `unknown[]` otherwise | a flat list of scalar items |
24
+ | `poi_list` | `PoiRow[]` | neighborhood points of interest |
25
+ | `url` | `string` (`string \| null` if optional) | a link the user edits in a URL field |
26
+ | `object_list` | `Record<string, unknown>[]` | repeating, independently-editable multi-field items |
27
+
28
+ The full per-type field shapes live in your editor's autocomplete and
29
+ [schema-system.md](schema-system.md) — this page is only *which to pick*.
30
+
31
+ ## `direct()` sources
32
+
33
+ The property facts a slot binds verbatim. A bad name is a compile error at
34
+ the `direct(...)` call site.
35
+
36
+ **Property facts** (resolve once per property):
37
+ - `address` — the full formatted address
38
+ - `listing_intent` — for-sale vs. for-rent state
39
+ - `status` — the listing status, free text as shown on the listing (e.g. "For Sale")
40
+ - `property_type` — the property type (`single_family` \| `condo_townhome` \| `multi_family` \| `investment`)
41
+ - `year_built` — the year the property was built
42
+ - `lot_size` — the lot size, pre-formatted text
43
+
44
+ **Per-unit facts** (resolve against the current/single unit row):
45
+ - `unit_label` — the unit's display label (e.g. "Unit 3B")
46
+ - `beds` — bedroom count for this unit
47
+ - `baths` — bathroom count for this unit
48
+ - `sqft` — square footage for this unit
49
+ - `price` — this unit's price, pre-formatted text
50
+ - `notes` — free-text notes on this unit
51
+
52
+ **List sources** (bind the whole array verbatim — pick these for `object_list`
53
+ slots that repeat over every row):
54
+ - `units` — every unit row on the property
55
+ - `contacts` — every contact attached to the section
56
+
57
+ **Per-contact fields** (resolve against the attached contact):
58
+ - `name` — the contact's display name
59
+ - `title` — the contact's job title/role
60
+ - `bio` — the contact's bio copy
61
+ - `email` — the contact's email address
62
+ - `phone` — the contact's phone number
63
+ - `website` — the contact's website URL
64
+ - `instagram` — the contact's Instagram handle/URL
65
+ - `linkedin` — the contact's LinkedIn handle/URL
66
+ - `x` — the contact's X (Twitter) handle/URL
67
+ - `facebook` — the contact's Facebook handle/URL
68
+
69
+ **Contact images** (bind only to `image` slots):
70
+ - `headshot` — the contact's headshot photo
71
+ - `logo_dark` — the contact's logo, dark-background variant
72
+ - `logo_light` — the contact's logo, light-background variant
73
+
74
+ ## `derived()` transforms
75
+
76
+ Computed values, grouped by family. A bad name is a compile error at the
77
+ `derived(...)` call site.
78
+
79
+ | transform | what it computes |
80
+ |---|---|
81
+ | `units.bedsPrimary` | beds of the primary (lowest-position) unit |
82
+ | `units.bathsPrimary` | baths of the primary unit |
83
+ | `units.sqftPrimary` | sqft of the primary unit |
84
+ | `units.pricePrimary` | price of the primary unit, pre-formatted text |
85
+ | `units.bedsRange` | formatted beds range across all units (e.g. "2–4"), collapsing to one value when uniform |
86
+ | `units.priceStart` | the lowest numeric unit price; falls back to the first non-numeric price (e.g. "Sold") when none is numeric |
87
+ | `units.bedsMin` | the minimum beds across all units, formatted |
88
+ | `units.bedsMax` | the maximum beds across all units, formatted |
89
+ | `units.bathsMin` | the minimum baths across all units, formatted |
90
+ | `units.bathsMax` | the maximum baths across all units, formatted |
91
+ | `units.sqftMax` | the maximum sqft across all units, formatted |
92
+ | `address.line1` | the property address's street line |
93
+ | `address.line2` | the property address's city/state/zip line |
94
+ | `contact_address.line1` | a contact's address street line |
95
+ | `contact_address.line2` | a contact's address city/state/zip line |
96
+ | `clock.currentYear` | the current year (e.g. for a copyright line) |
97
+ | `coordinate.lat` | latitude, split from the property's geocode |
98
+ | `coordinate.lng` | longitude, split from the property's geocode |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homepages/template-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Authoring kit for HomePages marketing-section templates: schema system, contract primitives, theme tokens, and the template-kit CLI.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,6 +41,10 @@
41
41
  "types": "./dist/browser.d.ts",
42
42
  "default": "./dist/browser.js"
43
43
  },
44
+ "./ssr": {
45
+ "types": "./dist/ssr.d.ts",
46
+ "default": "./dist/ssr.js"
47
+ },
44
48
  "./eslint": {
45
49
  "types": "./dist/eslint.d.ts",
46
50
  "default": "./dist/eslint.js"
@@ -55,7 +59,7 @@
55
59
  ],
56
60
  "scripts": {
57
61
  "prepack": "npm run build",
58
- "build": "tsdown",
62
+ "build": "tsdown && node scripts/build-dev-client.mjs && node scripts/build-cli-assets.mjs",
59
63
  "typecheck": "tsc --noEmit",
60
64
  "lint": "eslint .",
61
65
  "pretest": "node test/link-fixture-workspace.mjs",
@@ -75,12 +79,15 @@
75
79
  "@arethetypeswrong/cli": "^0.18.5",
76
80
  "@changesets/cli": "^2.29.8",
77
81
  "@eslint/js": "^9.39.4",
82
+ "@playwright/test": "^1.60.0",
78
83
  "@tailwindcss/cli": "^4.3.0",
79
84
  "@types/jsdom": "^21.1.7",
80
85
  "@types/node": "^20.17.10",
81
86
  "@types/react": "^19.0.0",
82
87
  "@types/react-dom": "^19.0.0",
83
88
  "@typescript-eslint/parser": "^8.60.1",
89
+ "@vitejs/plugin-react": "^4.3.4",
90
+ "chokidar": "^4.0.1",
84
91
  "esbuild": "^0.28.1",
85
92
  "eslint": "^9.39.4",
86
93
  "jsdom": "^26.1.0",
@@ -91,9 +98,11 @@
91
98
  "tsdown": "0.22.7",
92
99
  "tsx": "^4.19.2",
93
100
  "typescript": "^5.7.2",
94
- "typescript-eslint": "^8.60.1"
101
+ "typescript-eslint": "^8.60.1",
102
+ "vite": "^6.0.5"
95
103
  },
96
104
  "dependencies": {
105
+ "magic-string": "^0.30.21",
97
106
  "zod": "^4.4.3"
98
107
  }
99
108
  }
package/tsconfig.json CHANGED
@@ -3,5 +3,5 @@
3
3
  "compilerOptions": {
4
4
  "noEmit": true
5
5
  },
6
- "exclude": ["node_modules", "dist", ".tmp-consumer", "test/fixtures"]
6
+ "exclude": ["node_modules", "dist", ".tmp-consumer", "test/fixtures", "create-homepages-workspace"]
7
7
  }