@homepages/template-kit 0.0.1 → 0.2.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.
Files changed (121) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/README.md +77 -11
  3. package/dist/base.css +64 -0
  4. package/dist/browser.d.ts +26 -0
  5. package/dist/browser.js +55 -0
  6. package/dist/cli.js +1 -1
  7. package/dist/contracts/image-descriptor.d.ts +17 -0
  8. package/dist/contracts/markers.d.ts +20 -0
  9. package/dist/contracts/markers.js +21 -0
  10. package/dist/contracts/slot-catalog.d.ts +237 -0
  11. package/dist/contracts/slot-catalog.js +247 -0
  12. package/dist/contracts/transforms.d.ts +108 -0
  13. package/dist/contracts/transforms.js +117 -0
  14. package/dist/design-system/breakpoints.d.ts +5 -0
  15. package/dist/design-system/breakpoints.js +14 -0
  16. package/dist/design-system/theme.d.ts +308 -0
  17. package/dist/design-system/theme.js +102 -0
  18. package/dist/eslint/is-client-file.js +79 -0
  19. package/dist/eslint/rules/image-bare-needs-reason.js +43 -0
  20. package/dist/eslint/rules/no-client-directive-in-contract.js +31 -0
  21. package/dist/eslint/rules/no-client-runtime-in-server.js +103 -0
  22. package/dist/eslint/rules/no-css-import-from-render-path.js +29 -0
  23. package/dist/eslint/rules/no-hex.js +116 -0
  24. package/dist/eslint/rules/no-inline-style.js +27 -0
  25. package/dist/eslint/rules/no-nondeterminism.js +48 -0
  26. package/dist/eslint/rules/no-raw-element.js +37 -0
  27. package/dist/eslint/rules/props-from-schema.js +37 -0
  28. package/dist/eslint/rules/serializable-island-props.js +112 -0
  29. package/dist/eslint/rules/slot-marker-literal.js +38 -0
  30. package/dist/eslint.d.ts +4 -8
  31. package/dist/eslint.js +75 -11
  32. package/dist/fixtures/sample-property.d.ts +67 -0
  33. package/dist/fixtures/sample-property.js +707 -0
  34. package/dist/fixtures.d.ts +2 -0
  35. package/dist/fixtures.js +3 -0
  36. package/dist/index.d.ts +28 -12
  37. package/dist/index.js +34 -2
  38. package/dist/island-runtime.d.ts +30 -0
  39. package/dist/island-runtime.js +73 -0
  40. package/dist/islands/contract.d.ts +56 -0
  41. package/dist/islands/contract.js +71 -0
  42. package/dist/islands/marker.d.ts +16 -0
  43. package/dist/islands/marker.js +18 -0
  44. package/dist/package.js +5 -0
  45. package/dist/primitives/Image.d.ts +49 -0
  46. package/dist/primitives/Image.js +95 -0
  47. package/dist/primitives/Section.d.ts +22 -0
  48. package/dist/primitives/Section.js +26 -0
  49. package/dist/primitives/Slot.d.ts +50 -0
  50. package/dist/primitives/Slot.js +39 -0
  51. package/dist/primitives/SlotGroup.d.ts +38 -0
  52. package/dist/primitives/SlotGroup.js +24 -0
  53. package/dist/primitives/SlotItem.d.ts +43 -0
  54. package/dist/primitives/SlotItem.js +38 -0
  55. package/dist/primitives/index.d.ts +7 -0
  56. package/dist/primitives/picture-sources.d.ts +22 -0
  57. package/dist/primitives/picture-sources.js +52 -0
  58. package/dist/rules/registry.js +16 -0
  59. package/dist/schema/fill-spec.d.ts +692 -0
  60. package/dist/schema/fill-spec.js +98 -0
  61. package/dist/schema/fixture-schema.d.ts +143 -0
  62. package/dist/schema/fixture-schema.js +113 -0
  63. package/dist/schema/index.d.ts +16 -0
  64. package/dist/schema/manifest.d.ts +527 -0
  65. package/dist/schema/manifest.js +73 -0
  66. package/dist/schema/property-facts.d.ts +44 -0
  67. package/dist/schema/resolve-variants.d.ts +11 -0
  68. package/dist/schema/resolve-variants.js +15 -0
  69. package/dist/schema/runtime-values.d.ts +45 -0
  70. package/dist/schema/section-nav.d.ts +7 -0
  71. package/dist/schema/section-props.d.ts +69 -0
  72. package/dist/schema/section-schema.d.ts +533 -0
  73. package/dist/schema/section-schema.js +72 -0
  74. package/dist/schema/slot-types.d.ts +117 -0
  75. package/dist/schema/slot-types.js +181 -0
  76. package/dist/schema/source.d.ts +13 -0
  77. package/dist/schema/source.js +12 -0
  78. package/dist/schema/synthesize-nullable.d.ts +18 -0
  79. package/dist/schema/synthesize-nullable.js +47 -0
  80. package/dist/styles.css +131 -9
  81. package/docs/INDEX.md +7 -4
  82. package/docs/eslint.md +55 -0
  83. package/docs/islands.md +150 -0
  84. package/docs/llms.txt +33 -3
  85. package/docs/primitives.md +214 -0
  86. package/docs/rules/INDEX.md +58 -0
  87. package/docs/rules/audit-severity.md +91 -0
  88. package/docs/rules/bundle-incomplete.md +117 -0
  89. package/docs/rules/css-reason.md +94 -0
  90. package/docs/rules/determinism-drift.md +112 -0
  91. package/docs/rules/image-bare-needs-reason.md +117 -0
  92. package/docs/rules/license-denied.md +106 -0
  93. package/docs/rules/lockfile-missing.md +68 -0
  94. package/docs/rules/lockfile-stale.md +88 -0
  95. package/docs/rules/missing-slot-marker.md +140 -0
  96. package/docs/rules/no-bare-css-import.md +128 -0
  97. package/docs/rules/no-client-directive-in-contract.md +111 -0
  98. package/docs/rules/no-client-runtime-in-server.md +143 -0
  99. package/docs/rules/no-css-import-from-render-path.md +118 -0
  100. package/docs/rules/no-hex.md +97 -0
  101. package/docs/rules/no-inline-style.md +121 -0
  102. package/docs/rules/no-nondeterminism.md +100 -0
  103. package/docs/rules/no-raw-element.md +100 -0
  104. package/docs/rules/props-from-schema.md +119 -0
  105. package/docs/rules/render-invariant.md +165 -0
  106. package/docs/rules/serializable-island-props.md +146 -0
  107. package/docs/rules/server-vs-client.md +105 -0
  108. package/docs/rules/sidebar-order.md +109 -0
  109. package/docs/rules/single-react.md +97 -0
  110. package/docs/rules/size-renderer-bundle.md +109 -0
  111. package/docs/rules/size-section-css.md +95 -0
  112. package/docs/rules/slot-group-marker.md +127 -0
  113. package/docs/rules/slot-marker-literal.md +148 -0
  114. package/docs/rules/typecheck.md +103 -0
  115. package/docs/rules/unlayered-fence.md +113 -0
  116. package/docs/schema-system.md +240 -0
  117. package/docs/theme-and-css.md +225 -0
  118. package/package.json +30 -6
  119. package/tsconfig.base.json +16 -0
  120. package/tsconfig.json +2 -13
  121. package/dist/src-dZr5N30y.js +0 -35
package/docs/eslint.md ADDED
@@ -0,0 +1,55 @@
1
+ ---
2
+ purpose: The authoring-lint preset shipped at `@homepages/template-kit/eslint` — setup and scope. Per-rule reference lives in `rules/`.
3
+ status: living
4
+ related: [INDEX.md, rules/INDEX.md, islands.md, theme-and-css.md, primitives.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # ESLint preset
8
+
9
+ `@homepages/template-kit/eslint` is a flat-config ESLint preset carrying every
10
+ authoring rule the kit enforces one file at a time, under the `template-kit/*`
11
+ rule namespace.
12
+
13
+ ## Setup
14
+
15
+ ```js
16
+ // eslint.config.mjs
17
+ import kit from "@homepages/template-kit/eslint";
18
+ export default kit;
19
+ ```
20
+
21
+ That is the whole setup — no plugin registration, no parser wiring, no tsconfig.
22
+ The preset ships its own parser (`@typescript-eslint/parser`, a real `dependency`
23
+ of the kit, not a devDependency) and sets `languageOptions` itself, so `npm i -D
24
+ eslint` plus the three lines above is a complete consumer config.
25
+
26
+ The preset is **syntactic only** — it never sets `parserOptions.project` or
27
+ `projectService`. Nothing it enforces requires type information, which is what
28
+ keeps setup to zero extra config; a consumer's own `tsconfig.json` is never
29
+ consulted.
30
+
31
+ ## Scope
32
+
33
+ The preset is consumed **once at a workspace root** and lints every section
34
+ nested under `templates/**`; there are no per-template config files. It ships
35
+ three config entries, matched by glob rather than by directory depth:
36
+
37
+ | Entry | Files | Rules |
38
+ |---|---|---|
39
+ | `template-kit/authoring` | `**/*.ts`, `**/*.tsx` | the nine file-scoped rules — see [`rules/`](rules/INDEX.md) |
40
+ | `template-kit/contract-files` | `**/Renderer.tsx`, `**/schema.ts`, `**/fill-spec.ts`, `**/fixtures.ts` | `no-client-directive-in-contract` |
41
+ | `template-kit/renderer` | `**/Renderer.tsx` | `props-from-schema` |
42
+
43
+ Server-vs-client scoping is decided **per file**, by the `"use client"` directive
44
+ prologue — see [server vs client](rules/server-vs-client.md) for what that means
45
+ and exactly which rules stop at that boundary.
46
+
47
+ ## The rules
48
+
49
+ Every rule the preset enforces — what it bans, why, and a before/after fix — is
50
+ documented once, per id, in [`rules/`](rules/INDEX.md). Rules scoped to a single
51
+ file are enforced here, by this preset; rules scoped to the **tree** — a missing
52
+ contract file, a slot declared in `schema.ts` but unmarked in `Renderer.tsx`, a CSS
53
+ budget — cannot be expressed one-file-at-a-time and belong to `template-kit check`
54
+ under the same `template-kit/<id>` namespace. One namespace, two venues: the id
55
+ printed by either failure resolves to exactly one page.
@@ -0,0 +1,150 @@
1
+ ---
2
+ purpose: How interactivity works in a template — client components ("islands"), their props contract, and their editor options.
3
+ status: living
4
+ related: [INDEX.md, eslint.md]
5
+ updated: 2026-07-14
6
+ ---
7
+
8
+ # Islands: interactivity in a template
9
+
10
+ A section's `Renderer.tsx` is server-rendered and ships no JavaScript. When part of
11
+ a section needs to be interactive — a filter, a slider, a lightbox — you write an
12
+ ordinary React component in the section folder and mark it `"use client"`. It is
13
+ server-rendered with the rest of the page, then **hydrated** in the browser as its
14
+ own React root. That component is an *island*.
15
+
16
+ If a page contains no islands, it loads no React and no island code — every page
17
+ still ships the small island loader (a few KB, gzipped), but that loader only ever
18
+ reaches React through a dynamic import it takes when an island is actually on the page.
19
+
20
+ ## Writing one
21
+
22
+ ```tsx
23
+ // sections/floorplan/UnitFilter.tsx
24
+ "use client";
25
+
26
+ import { useState } from "react";
27
+
28
+ export default function UnitFilter({ units }: { units: { id: string; beds: number }[] }) {
29
+ const [beds, setBeds] = useState<number | null>(null);
30
+ const shown = beds === null ? units : units.filter((u) => u.beds === beds);
31
+ return ( /* … */ );
32
+ }
33
+ ```
34
+
35
+ Use it from the Renderer like any other component. The build wraps it in an island
36
+ marker and hydrates it on the published page:
37
+
38
+ ```tsx
39
+ import UnitFilter from "./UnitFilter.js";
40
+
41
+ export function Renderer({ units }: Props) {
42
+ return <UnitFilter units={units} />;
43
+ }
44
+ ```
45
+
46
+ Rules:
47
+
48
+ - **The component is default-exported.** One island per `"use client"` file.
49
+ - **Effects, state, event handlers, and browser APIs are all fine** inside an island —
50
+ it runs in the browser. They are *not* fine in a Renderer, which must stay a pure
51
+ function of its props.
52
+ - **Third-party client libraries are fine** inside an island (a slider, a map SDK).
53
+
54
+ ## Props must be JSON-serializable
55
+
56
+ Everything the Renderer passes to an island crosses a server→browser boundary as JSON:
57
+
58
+ | Allowed | Rejected |
59
+ |---|---|
60
+ | `string`, `number` (finite), `boolean`, `null` | functions, including event handlers |
61
+ | plain objects, arrays, nested combinations of the above | `Date`, `Map`, `Set`, `BigInt`, symbols, class instances |
62
+ | `undefined` values, per JSON: an object property is dropped, an array entry becomes `null` | `NaN`, `Infinity` |
63
+
64
+ A rejected prop fails the render, naming the path:
65
+
66
+ ```
67
+ IslandPropsError: props.units[0].created is a Date — island props must be
68
+ JSON-serializable (plain objects and arrays only)
69
+ ```
70
+
71
+ Pass an ISO string and parse it inside the island. Pass data, not callbacks — an
72
+ island owns its own handlers.
73
+
74
+ The `template-kit/serializable-island-props` [lint rule](eslint.md#islands) catches a
75
+ statically-visible violation (a literal arrow function, `new Date()`, a bigint literal,
76
+ `NaN`/`Infinity`) in your editor, before the render ever throws — a value assembled at
77
+ runtime and handed over as an identifier is invisible to the rule and stays this
78
+ runtime check's job.
79
+
80
+ ## Editor options
81
+
82
+ Templates are edited in a canvas where the section is re-rendered on every keystroke.
83
+ An island can declare how it behaves there:
84
+
85
+ ```tsx
86
+ "use client";
87
+ import type { IslandEditorOptions } from "@homepages/template-kit";
88
+
89
+ export default function Accordion({ items, singleOpen }: Props) { /* … */ }
90
+
91
+ export const editor: IslandEditorOptions = {
92
+ live: true, // hydrate in the editor canvas (default: false)
93
+ shieldMode: "fill-parent", // how the editor sizes its selection shield
94
+ liveSurface: (root) => [...root.querySelectorAll(".accordion")], // shield only these
95
+ props: { singleOpen: true }, // editor-only props, merged over the real ones
96
+ };
97
+ ```
98
+
99
+ Omit `editor` entirely and the island simply renders as static markup in the editor —
100
+ which is the right choice for most islands.
101
+
102
+ ## The DOM contract
103
+
104
+ Rarely needed while authoring, but this is what the build emits, and what a host
105
+ hydrates:
106
+
107
+ ```html
108
+ <tr-island style="display:contents" data-tr-island="<key>" data-tr-island-id="<id>">
109
+ …server-rendered output…
110
+ </tr-island>
111
+ <script type="application/json" data-tr-island-props="<id>">{"units":[…]}</script>
112
+ ```
113
+
114
+ The marker is layout-invisible (`display:contents`), so an island can sit directly
115
+ inside a grid or flex container without adding a box.
116
+
117
+ ## Hosting islands yourself
118
+
119
+ `@homepages/template-kit/island-runtime` is the loader. It is not something a template
120
+ author calls — it is what a page (or the editor) calls to bring islands to life:
121
+
122
+ ```ts
123
+ import { hydrateIslands, unmountIslands } from "@homepages/template-kit/island-runtime";
124
+
125
+ await hydrateIslands({ load: (key) => ISLANDS[key]() }); // ISLANDS: the build's island map
126
+ unmountIslands(sectionEl); // before replacing sectionEl's HTML
127
+ ```
128
+
129
+ Each island is an independent React root: one island's state, re-renders, and errors
130
+ never touch another's. Hydration is idempotent, so calling `hydrateIslands` again after
131
+ injecting new markup is safe.
132
+
133
+ ## Talking to HomePages from an island
134
+
135
+ `@homepages/template-kit/browser` carries the helpers a published page needs to reach the
136
+ HomePages ingest endpoints — reading the site's embedded config and signing requests:
137
+
138
+ ```ts
139
+ import { readSiteConfig, signedIngestHeaders } from "@homepages/template-kit/browser";
140
+
141
+ const config = readSiteConfig();
142
+ if (config) {
143
+ const body = JSON.stringify({ name, email });
144
+ await fetch(`${config.ingestBase}/leads/ingest`, {
145
+ method: "POST",
146
+ headers: await signedIngestHeaders(config, body),
147
+ body,
148
+ });
149
+ }
150
+ ```
package/docs/llms.txt CHANGED
@@ -4,10 +4,40 @@
4
4
  > inside the npm package so an agent can read it locally, versioned in lockstep
5
5
  > with the code it describes.
6
6
 
7
- Status: 0.0.x package skeleton. The authoring corpus (task recipes, rule-id
8
- reference, vocabulary) arrives with the surface it documents: the schema system,
9
- the contract primitives and theme tokens, and the `check` command's rule-ids.
7
+ Status: 0.0.x. The schema system, the contract primitives, and the theme tokens are
8
+ complete a section is authorable end to end against the kit. The `template-kit`
9
+ CLI's commands arrive with the surfaces they drive.
10
+
11
+ Everything an author writes a section against is imported from the package root —
12
+ `@homepages/template-kit`, no subpath imports. The two CSS entries
13
+ (`./styles.css`, `./base.css`) are the deliberate exception.
10
14
 
11
15
  ## Docs
12
16
 
17
+ - [Schema system](schema-system.md): the section authoring contract. A section is
18
+ four files — `schema.ts` (slots + options + `meta.groups`), `fill-spec.ts` (how AI
19
+ fills them), `fixtures.ts` (preview content, built on the kit's golden property
20
+ dataset), `Renderer.tsx` (markup, with props DERIVED from the schema via
21
+ `SectionProps<typeof schema>`). Covers the `as const satisfies` requirement, binding
22
+ slots to property facts with `direct()`/`derived()` over a closed vocabulary, and
23
+ fact-driven variants.
24
+ - [Contract primitives](primitives.md): the only five components the kit ships —
25
+ `Section` (full-bleed section root), `Slot` (`data-slot-id`), `SlotItem` (per-item
26
+ handle in an `object_list` / `image_collection`), `SlotGroup` (`data-slot-group`),
27
+ and `Image` (responsive `<picture>`, empty-state box). Plus the marker-attribute
28
+ contract (`ATTR_SLOT_ID`, `ATTR_SLOT_ITEM`, …). Presentation is the template's own JSX.
29
+ - [Theme and CSS](theme-and-css.md): the two CSS entries and how they load; import
30
+ Tailwind exactly once; `base.css` ships no reset of its own — it requires the
31
+ preflight your Tailwind entry brings; `TokenTheme` + `compileThemeToCss`; the breakpoint ladder
32
+ (base 350, `bp390` `bp425` `bp512` `md` `lg` `bp1440` `bp1920`); the opt-in
33
+ `content-visibility` recipe for long, image-heavy pages, and why it requires a
34
+ full-screen overlay to be appended to `document.body` rather than rendered inside
35
+ the section.
13
36
  - [README](../README.md): install, exports, and the release flow.
37
+ - [Islands](islands.md): interactivity — client components, the serializable-props contract, editor options, and the browser loader.
38
+ - [ESLint preset](eslint.md): `@homepages/template-kit/eslint`, the zero-config flat-config
39
+ preset — setup and its three-entry glob scope. Per-rule reference lives in `rules/`.
40
+ - [Rules](rules/INDEX.md): every rule-id a lint or `check` failure can print, under the
41
+ `template-kit/<id>` namespace, one page each at `docs/rules/<id>.md` — Rule / Reason /
42
+ Fix / Before→After. **The loop:** see an id in a failure → read `docs/rules/<id>.md` →
43
+ fix. Nothing else to search for; the id is the filename.
@@ -0,0 +1,214 @@
1
+ ---
2
+ purpose: The five contract primitives a section renderer must use, and the marker attributes they emit.
3
+ status: living
4
+ related: [schema-system.md, theme-and-css.md, eslint.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # Contract primitives
8
+
9
+ Everything here is imported from the package root:
10
+
11
+ ```tsx
12
+ import { Image, Section, Slot, SlotGroup, SlotItem } from "@homepages/template-kit";
13
+ ```
14
+
15
+ The kit ships **five components, and nothing else**. That is deliberate: a primitive
16
+ earns its place only by emitting something the platform breaks without — the section
17
+ box model, the editor's marker attributes, the responsive-image machinery.
18
+ Presentation is yours. Headings, links, layout wells, icons, lists, cards: write them
19
+ as JSX in your section, or reach for a package.
20
+
21
+ | Primitive | Emits | Required when |
22
+ |---|---|---|
23
+ | `Section` | `class="tr-section"` on the section root | always — every renderer roots at exactly one |
24
+ | `Slot` | `data-slot-id` (+ optional `data-slot-text-leaf`) | around every editable slot's outer DOM |
25
+ | `SlotItem` | `data-slot-item` | around each item of an `object_list` / `image_collection` slot |
26
+ | `SlotGroup` | `data-slot-group` | around the members of a group declared in `meta.groups` |
27
+ | `Image` | `<picture>` / `srcset` / the empty-state box | every image — sections never write a raw `<img>` |
28
+
29
+ A slot with no marker in the DOM **cannot be selected or edited**. The markers are the
30
+ only thing connecting the pixels a user clicks to the schema key behind them.
31
+
32
+ ## `Section` — the section root
33
+
34
+ ```tsx
35
+ export default function Hero({ slots, nav }: Props) {
36
+ return (
37
+ <Section id={nav.selfAnchor} className="bg-surface py-16">
38
+
39
+ </Section>
40
+ );
41
+ }
42
+ ```
43
+
44
+ Renders `<section class="tr-section">` and merges any `className` you pass. `as` picks
45
+ a different element from `section | div | article | header | footer | aside`.
46
+
47
+ `.tr-section` (from [`base.css`](theme-and-css.md)) is **full-bleed by contract**: 100%
48
+ width, zero padding, zero margin, so consecutive sections butt edge-to-edge and a page
49
+ composes as a vertical stack with no framework-injected whitespace. Vertical separation
50
+ is the section's own job — a background and padding on this root or a child, never a
51
+ margin that would re-open gaps between sections.
52
+
53
+ A centered, max-width well is plain JSX on a child:
54
+
55
+ ```tsx
56
+ <div className="w-full max-w-[var(--tr-container-max)] mx-auto px-[var(--tr-container-pad)]">
57
+ ```
58
+
59
+ ## `Slot` — the editable-slot boundary
60
+
61
+ ```tsx
62
+ <Slot id="headline" as="h1" textLeaf className="text-4xl">
63
+ <span>{slots.headline}</span>
64
+ </Slot>
65
+ ```
66
+
67
+ | Prop | Meaning |
68
+ |---|---|
69
+ | `id` | The slot key from `schema.ts`, written as a string literal. Emitted as `data-slot-id`. |
70
+ | `as` | Element to render. Default `"div"`. |
71
+ | `textLeaf` | For a single-leaf `text` slot: marks the immediate child element with `data-slot-text-leaf`. |
72
+ | …rest | Any HTML attribute — `className`, `style`, ARIA, `data-*`. |
73
+
74
+ `textLeaf` is what lets the editor write `textContent` on the leaf on every keystroke
75
+ (the optimistic path). It only handles the **single element child** case: with raw text,
76
+ a fragment, or several children it is a silent no-op — the outer `data-slot-id` still
77
+ lands, the editor just skips the optimistic update and converges on the next render. A
78
+ multi-leaf slot (an address with one leaf per line) marks its leaves itself.
79
+
80
+ `Slot` still emits its marker when it has no children. That is what keeps an unfilled
81
+ slot selectable, so a user can click the empty shell and type into it.
82
+
83
+ Not every slot needs a `<Slot>` wrapper: a primitive with a natural single root for its
84
+ slot type takes a `slotId` prop and emits the marker itself — `<Image slotId="hero_image">`
85
+ needs no wrapper. `<Slot>` is for everything else.
86
+
87
+ ## `SlotItem` — the per-item handle in a collection
88
+
89
+ A collection slot (`object_list`, `image_collection`) needs **both** markers: `<Slot>`
90
+ marks the whole array, `<SlotItem>` marks one row of it.
91
+
92
+ ```tsx
93
+ <Slot id="amenities">
94
+ {slots.amenities.map((item, i) => (
95
+ <SlotItem key={item.id} index={i} as="li" className="border-b">
96
+ <h3>{item.label}</h3>
97
+ </SlotItem>
98
+ ))}
99
+ </Slot>
100
+ ```
101
+
102
+ The editor resolves the slot from the ancestor `data-slot-id`, then the specific item
103
+ from the nearest `data-slot-item`. Without `SlotItem` a collection is editable only as a
104
+ whole — no per-item selection, no per-item image cropping, no per-item live surfaces.
105
+
106
+ `index` **must** be the item's zero-based position in the slot's array as rendered. The
107
+ editor writes edits back by that index, so an index that doesn't match its position edits
108
+ the wrong row. `index` is an array position, not a schema key, so unlike `Slot`'s and
109
+ `SlotGroup`'s `id` it is exempt from the [`template-kit/slot-marker-literal`](eslint.md#markers-and-schema)
110
+ lint rule.
111
+
112
+ ## `SlotGroup` — slots edited as one unit
113
+
114
+ Grouping is authored in two places that must agree.
115
+
116
+ `schema.ts` declares the group in `meta.groups`, which collapses its members into one
117
+ card in the editor sidebar:
118
+
119
+ ```ts
120
+ meta: {
121
+ displayName: "Contact",
122
+ groups: [
123
+ { id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
124
+ ],
125
+ },
126
+ ```
127
+
128
+ `Renderer.tsx` wraps the members' shared container, which makes the canvas outline and
129
+ select them as the single unit matching that one card:
130
+
131
+ ```tsx
132
+ <SlotGroup id="agent" className="flex flex-col gap-2">
133
+ <Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
134
+ <Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
135
+ <Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
136
+ </SlotGroup>
137
+ ```
138
+
139
+ The primitive's `id` **must equal** the group's `id` in `meta.groups`. `SlotGroup` must
140
+ be an **ancestor** of every member's `data-slot-id` element — the editor walks up with
141
+ `closest("[data-slot-group]")`, so a marker that isn't an ancestor is never found.
142
+
143
+ ## `Image` — the defensive image primitive
144
+
145
+ Sections never write a raw `<img>`. `Image` owns the responsive `<picture>`, the
146
+ empty-state box, and the layout box that survives a missing source.
147
+
148
+ ```tsx
149
+ <Image
150
+ slotId="hero_image"
151
+ src={slots.hero_image.url}
152
+ alt={slots.hero_image.alt}
153
+ responsive={slots.hero_image.responsive}
154
+ sizes="(min-width: 1024px) 1200px, 100vw"
155
+ aspectRatio="5 / 3"
156
+ />
157
+ ```
158
+
159
+ | Prop | Meaning |
160
+ |---|---|
161
+ | `src` | Image URL. Required prop; `null`/empty is a supported state, not a bug. |
162
+ | `alt` | Required. Empty string for a decorative image. |
163
+ | `slotId` | Emits `data-slot-id` on the wrapper — an image slot needs no `<Slot>` around it. |
164
+ | `aspectRatio` | CSS `aspect-ratio` reserved on the wrapper. Survives a missing `src`. |
165
+ | `responsive` | The platform's `ResponsiveImage` descriptor (`ImageValue.responsive`). |
166
+ | `responsiveMobile` | Mobile-crop descriptor (`ImageValue.mobile.responsive`); needs `mobileBreakpoint`. |
167
+ | `mobileBreakpoint` | Desktop↔mobile switch width in px. The mobile `<source>` gets `max-width: breakpoint − 1`. |
168
+ | `sizes` | The slot's `sizes` attribute, applied to every srcset candidate. |
169
+ | `maxRungWidth` | Per-section srcset ceiling in px. Wider rungs are dropped (the smallest is always kept). |
170
+ | `bare` | Escape hatch: a single `<img>`, no wrapper. |
171
+
172
+ **Framed (the default).** A wrapper `div` carries the slot marker, holds the aspect-ratio
173
+ box, and absolutely-positions the inner `<img>` with `object-cover`. Cropping happens
174
+ upstream — the platform delivers a pre-cropped asset — so the image is simply centered.
175
+ When `src` is empty the wrapper renders alone as a neutral `role="img"` rect, keeping the
176
+ layout box and the slot selectable. **That deterministic empty state is the reason the
177
+ frame exists**, so pass `aspectRatio` rather than sizing the image from its own content.
178
+
179
+ **Responsive.** Given a `responsive` descriptor the `<img>` is wrapped in `<picture>` with
180
+ per-format AVIF/WebP `<source srcset>` (the base raster stays on the `<img>`), plus a
181
+ media-gated mobile crop when `responsiveMobile` + `mobileBreakpoint` are set, and carries
182
+ `sizes` and intrinsic `width`/`height`. With no descriptor it degrades to a plain single
183
+ `<img src>` — no `srcset`, no `<picture>`.
184
+
185
+ **Bare.** `bare` renders one `<img class="w-full">` with no wrapper, for chrome images
186
+ whose parent already establishes the layout box (a logo you want `object-contain`, a slide
187
+ inside its own aspect-ratio parent). It deliberately sets no `object-fit`, so an
188
+ `object-contain` you pass isn't fighting a built-in `object-cover`. It forfeits the
189
+ empty-state box — reach for it only when the parent guarantees the box.
190
+
191
+ ## The marker contract
192
+
193
+ The primitives are the sanctioned way to produce these attributes. The constants are
194
+ exported so a section's own browser-side code can query the DOM without hardcoding a
195
+ magic string.
196
+
197
+ | Constant | Attribute | On |
198
+ |---|---|---|
199
+ | `ATTR_SLOT_ID` | `data-slot-id` | the `Slot` wrapper / `Image`'s framed wrapper |
200
+ | `ATTR_SLOT_TEXT_LEAF` | `data-slot-text-leaf` | the text leaf inside a text slot |
201
+ | `ATTR_SLOT_ITEM` | `data-slot-item` | one item of a collection slot |
202
+ | `ATTR_SLOT_GROUP` | `data-slot-group` | the container wrapping a group's members |
203
+ | `ATTR_SECTION_INSTANCE_ID` | `data-section-instance-id` | the section root — **written by the platform**, not by a primitive |
204
+ | `ATTR_TR_ENHANCE` | `data-tr-enhance` | opt-in hook telling an enhancer to attach to this element |
205
+
206
+ Changing what an attribute means is a platform contract break, so these names are stable
207
+ across a major version of the kit.
208
+
209
+ ## Renderers stay pure
210
+
211
+ Renderers are pure functions of props: no fetches, no globals, no `useEffect` for data,
212
+ no `Date.now()` / `Math.random()`. They are rendered to static markup by the publisher
213
+ and re-mounted constantly by the editor. Interactivity that needs the browser attaches
214
+ through `data-tr-enhance`, not through render-time side effects.
@@ -0,0 +1,58 @@
1
+ ---
2
+ purpose: Router for the rule corpus — every template-kit/<id> an author can hit, and the page that explains it.
3
+ status: living
4
+ related: [server-vs-client.md, ../eslint.md, ../INDEX.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # Rules
8
+
9
+ Every authoring rule the kit enforces is printed under one namespace, `template-kit/<id>`,
10
+ and resolves to exactly one page here: `docs/rules/<id>.md`.
11
+
12
+ **One namespace, two venues.** Some rules are properties of **one file** (a `Date.now()`
13
+ in a Renderer) and are enforced by the ESLint preset. Others are properties of the
14
+ **tree** (a slot declared in `schema.ts` but never marked in the JSX, a stylesheet, a
15
+ dependency, a byte budget) and cannot be expressed one file at a time; those are enforced
16
+ by `template-kit check`. You never need to know which engine produced a violation — only
17
+ the id, which names its page either way.
18
+
19
+ Read [server vs client](server-vs-client.md) first if you are here for any rule that
20
+ mentions "server-rendered code" or an island. It is the one concept those rules assume,
21
+ and it is explained there rather than on each of them.
22
+
23
+ | Rule | Venue | What it enforces |
24
+ |---|---|---|
25
+ | [`no-nondeterminism`](no-nondeterminism.md) | eslint | No clock or randomness reads in server-rendered code. |
26
+ | [`no-client-runtime-in-server`](no-client-runtime-in-server.md) | eslint | No effects, state, event handlers, network, or browser globals in server-rendered code. |
27
+ | [`serializable-island-props`](serializable-island-props.md) | eslint | Props handed to an island must be JSON-serializable. |
28
+ | [`no-client-directive-in-contract`](no-client-directive-in-contract.md) | eslint | No "use client" in a section's four contract files. |
29
+ | [`props-from-schema`](props-from-schema.md) | eslint | A Renderer's Props type must be derived from schema.ts, not hand-written. |
30
+ | [`no-inline-style`](no-inline-style.md) | eslint | No inline style= in server-rendered JSX. Islands are exempt. |
31
+ | [`no-raw-element`](no-raw-element.md) | eslint | No raw `<section>` or `<img>` — use `<Section>` and `<Image>`. |
32
+ | [`image-bare-needs-reason`](image-bare-needs-reason.md) | eslint | `<Image bare>` needs a `// bare:` comment saying why it skips the frame. |
33
+ | [`slot-marker-literal`](slot-marker-literal.md) | eslint | A slot-marker key must be a string literal, not an expression. |
34
+ | [`no-hex`](no-hex.md) | eslint | No hard-coded colours. Islands are NOT exempt. |
35
+ | [`no-css-import-from-render-path`](no-css-import-from-render-path.md) | eslint | No relative/absolute .css import from a section's render path — a package's is the sanctioned channel. |
36
+ | [`bundle-incomplete`](bundle-incomplete.md) | check | A section folder is missing one of its four required files. |
37
+ | [`missing-slot-marker`](missing-slot-marker.md) | check | An editable slot in schema.ts has no marker in the section's JSX. |
38
+ | [`slot-group-marker`](slot-group-marker.md) | check | A group with two or more members has no `<SlotGroup>` wrapper. |
39
+ | [`css-reason`](css-reason.md) | check | A hand-written CSS file must open with a `css-reason:` comment. |
40
+ | [`no-bare-css-import`](no-bare-css-import.md) | check | No @import in hand-written CSS — the browser drops it silently. |
41
+ | [`unlayered-fence`](unlayered-fence.md) | check | An `/* unlayered: */` escape hatch must be balanced and give a reason. |
42
+ | [`sidebar-order`](sidebar-order.md) | check | Schema slot order must match the order the page renders them in. |
43
+ | [`typecheck`](typecheck.md) | check | The template must compile under TypeScript. |
44
+ | [`determinism-drift`](determinism-drift.md) | check | Rendering the same fixture twice must produce byte-identical HTML. |
45
+ | [`render-invariant`](render-invariant.md) | check | Rendered output must carry no [object Object], bare null/undefined/NaN leaf, or src-less `<img>`. |
46
+ | [`lockfile-missing`](lockfile-missing.md) | check | The workspace has no package-lock.json. |
47
+ | [`lockfile-stale`](lockfile-stale.md) | check | The lockfile does not satisfy package.json — an `npm ci` would fail. |
48
+ | [`single-react`](single-react.md) | check | The installed tree carries more than one copy of react or react-dom. |
49
+ | [`audit-severity`](audit-severity.md) | check | A dependency carries a high or critical security advisory. |
50
+ | [`license-denied`](license-denied.md) | check | A production dependency's license is not on the allowlist. |
51
+ | [`size-renderer-bundle`](size-renderer-bundle.md) | check | A section's renderer bundle exceeds its byte budget. |
52
+ | [`size-section-css`](size-section-css.md) | check | A section's compiled CSS exceeds its byte budget. |
53
+
54
+ ## See also
55
+
56
+ - [Server vs client](server-vs-client.md) — how a file is scoped, and which rules stop at
57
+ the hydration boundary.
58
+ - [ESLint preset](../eslint.md) — setting the preset up in a template workspace.
@@ -0,0 +1,91 @@
1
+ ---
2
+ purpose: The template-kit/audit-severity authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, license-denied.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/audit-severity
8
+
9
+ > A dependency with a **high** or **critical** advisory fails. Low and moderate pass, on
10
+ > purpose. There is no way to suppress it.
11
+
12
+ ## Rule
13
+
14
+ `npm audit` runs over your workspace, and a violation is reported for each advisory at
15
+ severity **high** or **critical**.
16
+
17
+ **Low and moderate advisories are a deliberate pass.** They are not "not yet enforced" —
18
+ they are not enforced. Nobody will ask you to clear them.
19
+
20
+ **There is no suppression mechanism.** No inline waiver, no ignore file, no severity
21
+ override. If you are looking for the flag, there isn't one, and that is the design.
22
+
23
+ ## Reason
24
+
25
+ The kit lets you reach for third-party packages — a carousel, a map SDK, a date formatter —
26
+ because a section that has to hand-write everything is a section nobody finishes. The
27
+ consequence is that your dependency tree ships to a published page we host for a customer,
28
+ so the supply chain is part of the submission.
29
+
30
+ This gate is the **floor**, not a review. Cutting at high/critical is what keeps it a floor:
31
+ a rule that fired on every transitive moderate would be a rule everyone learned to route
32
+ around, and the exception would swallow the check. Two severities, no exceptions, and a
33
+ clear answer to "can I ship this."
34
+
35
+ ## Fix
36
+
37
+ In order of preference:
38
+
39
+ 1. **`npm audit fix`** — resolves most advisories by moving a transitive dependency inside
40
+ its existing range.
41
+ 2. **Upgrade the offending package.** If the fix is in a new major, `npm audit fix --force`
42
+ will take it, but read what it changes first.
43
+ 3. **Drop the package.** An advisory with no fix available is a package with no fix
44
+ available. Replace it, or hand-write the part of it you actually use.
45
+
46
+ Then re-run `npm audit` and commit the lockfile.
47
+
48
+ ### Before
49
+
50
+ ```text
51
+ $ npm audit
52
+ # npm audit report
53
+
54
+ marked <4.0.10
55
+ Severity: high
56
+ Inefficient Regular Expression Complexity in marked
57
+ fix available via `npm audit fix --force`
58
+
59
+ 1 high severity vulnerability
60
+ ```
61
+
62
+ ```jsonc
63
+ // package.json
64
+ {
65
+ "dependencies": {
66
+ "marked": "^3.0.8"
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### After
72
+
73
+ ```jsonc
74
+ // package.json
75
+ {
76
+ "dependencies": {
77
+ "marked": "^14.1.2"
78
+ }
79
+ }
80
+ ```
81
+
82
+ ```text
83
+ $ npm audit
84
+ found 0 vulnerabilities
85
+ ```
86
+
87
+ ## See also
88
+
89
+ - [`license-denied`](license-denied.md) — the other supply-chain gate on your production
90
+ dependencies.
91
+ - [`lockfile-stale`](lockfile-stale.md) — commit the lockfile the fix regenerated.