@homepages/template-kit 0.7.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @homepages/template-kit
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3f9d98e: `renderSectionHtml` now emits hydration-ready markup (`renderToString`) instead of
8
+ static markup. Sections carrying a `"use client"` island could not hydrate in
9
+ `template-kit dev`, because static markup strips the markers the island runtime
10
+ needs. Rendered HTML gains React's `<!-- -->` text separators, so any test
11
+ asserting exact HTML from `@homepages/template-kit/ssr` — or from `template-kit
12
+ check` — needs its expectation updated.
13
+
14
+ ### Patch Changes
15
+
16
+ - 735cfae: Islands now declare their editor-canvas behavior explicitly. A "use client" component with
17
+ a default export must `export const editor` with a boolean `live`: `{ live: true }` hydrates
18
+ it in the editor canvas, `{ live: false }` leaves it as static server-rendered HTML there.
19
+ The new `require-island-editor` rule reports an island that declares neither. Previously an
20
+ undeclared island silently stayed static — the same code for "static, on purpose" and "not
21
+ thought about yet". Add `export const editor = { live: false };` to keep an existing
22
+ island's current behavior. The new `IslandEditor` type is exported for `satisfies`.
23
+ - dad1c9a: Repository layout only: both packages now live under `packages/*` behind a private
24
+ workspace root. No changes to the published API, exports, types, CLI, or the
25
+ `create-homepages-workspace` scaffolding experience — installing and using either
26
+ package is exactly as before.
27
+
3
28
  ## 0.7.0
4
29
 
5
30
  ### Minor Changes
@@ -1,14 +1,10 @@
1
1
  import { FixtureModuleShape, buildInvariantFixtures } from "../../../schema/fixture-schema.js";
2
2
  import { loadSection } from "../loader.js";
3
3
  import { flattenLayout } from "../../../schema/rows.js";
4
+ import { renderSectionHtml } from "../../../ssr.js";
4
5
  import { checkRenderInvariants } from "../render-invariants.js";
5
- import { createElement } from "react";
6
- import { renderToStaticMarkup } from "react-dom/server";
7
6
 
8
7
  //#region src/cli/check/stages/render.ts
9
- function renderSectionHtml(renderer, props) {
10
- return renderToStaticMarkup(createElement(renderer, props));
11
- }
12
8
  const SLOT_ID_RE = /data-slot-id="([^"]*)"/g;
13
9
  function renderSlotOrder(html) {
14
10
  const seen = /* @__PURE__ */ new Set();
@@ -98,4 +94,4 @@ async function renderStage(workspaceRoot, template) {
98
94
  }
99
95
 
100
96
  //#endregion
101
- export { checkSidebarOrder, renderSectionHtml, renderStage };
97
+ export { checkSidebarOrder, renderStage };
@@ -1,13 +1,12 @@
1
1
  import { FixtureModuleShape, buildFixtureSet } from "../../../../schema/fixture-schema.js";
2
2
  import { TemplateManifest } from "../../../../schema/manifest.js";
3
3
  import { loadSection } from "../../loader.js";
4
+ import { renderSectionHtml } from "../../../../ssr.js";
4
5
  import { resolveSectionRef } from "../../../../schema/resolve-section-ref.js";
5
6
  import { materializeSection } from "./orchestrator.js";
6
7
  import { resolveAnchorMap } from "../../../../schema/section-nav.js";
7
- import { createElement } from "react";
8
8
  import { join } from "node:path";
9
9
  import { readFile } from "node:fs/promises";
10
- import { renderToStaticMarkup } from "react-dom/server";
11
10
 
12
11
  //#region src/cli/check/stages/validate/nav-contract.ts
13
12
  const ALLOWED_EXTERNAL_ANCHORS = ["#privacy-policy"];
@@ -75,7 +74,7 @@ async function assertNavContract(workspaceRoot, template) {
75
74
  selfAnchor: anchor
76
75
  }).find((f) => f.id === "typical");
77
76
  if (!base) throw new Error(`Template "${template.key}": section "${section.name}" has no "typical" base fixture.`);
78
- html += renderToStaticMarkup(createElement(rendererByName.get(section.name), {
77
+ html += renderSectionHtml(rendererByName.get(section.name), {
79
78
  slots: base.props.slots,
80
79
  options: base.props.options,
81
80
  nav: {
@@ -83,7 +82,7 @@ async function assertNavContract(workspaceRoot, template) {
83
82
  anchors
84
83
  },
85
84
  variants: base.props.variants
86
- }));
85
+ });
87
86
  }
88
87
  const violations = collectNavViolations(html, ALLOWED_EXTERNAL_ANCHORS);
89
88
  if (violations.length > 0) throw new Error(`Template "${template.key}": nav link(s) resolve to no composed section id: ${violations.join(", ")}.`);
@@ -30,6 +30,7 @@ function decode(s) {
30
30
  return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'");
31
31
  }
32
32
  function parseHtml(html) {
33
+ html = html.replace(/<!--[\s\S]*?-->/g, "");
33
34
  const root = {
34
35
  tag: "#root",
35
36
  classes: [],
@@ -7,7 +7,7 @@ const here = dirname(fileURLToPath(import.meta.url));
7
7
  function assetsRoot() {
8
8
  const dist = join(here, "scaffold");
9
9
  if (existsSync(dist)) return dist;
10
- return join(here, "..", "..", "..", "create-homepages-workspace", "scaffold");
10
+ return join(here, "..", "..", "..", "..", "create-homepages-workspace", "scaffold");
11
11
  }
12
12
  function templateAssetsDir() {
13
13
  return join(assetsRoot(), "template");
@@ -0,0 +1,74 @@
1
+ import { docsPathForRule } from "../../rules/registry.js";
2
+ import { hasClientDirective } from "../is-client-file.js";
3
+
4
+ //#region src/eslint/rules/require-island-editor.ts
5
+ function unwrap(node) {
6
+ let current = node;
7
+ while (current && (current.type === "TSSatisfiesExpression" || current.type === "TSAsExpression" || current.type === "TSTypeAssertion")) current = current.expression;
8
+ return current;
9
+ }
10
+ function propNamed(object, want) {
11
+ for (const prop of object.properties ?? []) {
12
+ if (prop.type !== "Property" || prop.computed || prop.kind !== "init") continue;
13
+ if ((prop.key.type === "Identifier" ? prop.key.name : prop.key.type === "Literal" ? String(prop.key.value) : null) === want) return prop;
14
+ }
15
+ }
16
+ const requireIslandEditor = {
17
+ meta: {
18
+ type: "problem",
19
+ docs: {
20
+ description: "Every island must declare whether it is live in the editor canvas",
21
+ url: docsPathForRule("require-island-editor")
22
+ },
23
+ schema: [],
24
+ messages: {
25
+ missingEditor: "this island does not declare its editor-canvas behavior — add `export const editor`. Fix: `export const editor = { live: false };` if it should stay static SSR HTML in the editor, or `{ live: true }` to hydrate it there behind the selection gate.",
26
+ missingLive: "`editor` does not declare `live`. Fix: add `live: true` to hydrate this island in the editor canvas, or `live: false` to leave it as static SSR HTML there. There is no default — an omitted `live` used to mean `false` silently, which is the bug this rule exists to prevent.",
27
+ liveNotBoolean: "`editor.live` must be `true` or `false` written literally, not a computed value. Fix: write the literal. The editor host reads `editor` once when the module loads, so a value that varies at runtime cannot mean anything — and a declaration a reader cannot see is the very thing this rule exists to prevent."
28
+ }
29
+ },
30
+ create(context) {
31
+ return { "Program:exit"(program) {
32
+ if (!hasClientDirective(context.sourceCode.getText())) return;
33
+ let hasDefaultExport = false;
34
+ let editorInit;
35
+ let editorReportNode;
36
+ for (const stmt of program.body) {
37
+ if (stmt.type === "ExportDefaultDeclaration") {
38
+ hasDefaultExport = true;
39
+ continue;
40
+ }
41
+ if (stmt.type !== "ExportNamedDeclaration") continue;
42
+ const declaration = stmt.declaration;
43
+ if (declaration?.type !== "VariableDeclaration") continue;
44
+ for (const decl of declaration.declarations) if (decl.id.type === "Identifier" && decl.id.name === "editor") {
45
+ editorInit = unwrap(decl.init);
46
+ editorReportNode = decl.init ?? decl.id;
47
+ }
48
+ }
49
+ if (!hasDefaultExport) return;
50
+ if (!editorReportNode) {
51
+ context.report({
52
+ node: program,
53
+ messageId: "missingEditor"
54
+ });
55
+ return;
56
+ }
57
+ const live = editorInit?.type === "ObjectExpression" ? propNamed(editorInit, "live") : void 0;
58
+ if (!live) {
59
+ context.report({
60
+ node: editorReportNode,
61
+ messageId: "missingLive"
62
+ });
63
+ return;
64
+ }
65
+ if (live.value.type !== "Literal" || typeof live.value.value !== "boolean") context.report({
66
+ node: live.value,
67
+ messageId: "liveNotBoolean"
68
+ });
69
+ } };
70
+ }
71
+ };
72
+
73
+ //#endregion
74
+ export { requireIslandEditor };
package/dist/eslint.js CHANGED
@@ -7,6 +7,7 @@ import { noInlineStyle } from "./eslint/rules/no-inline-style.js";
7
7
  import { noNondeterminism } from "./eslint/rules/no-nondeterminism.js";
8
8
  import { noRawElement } from "./eslint/rules/no-raw-element.js";
9
9
  import { propsFromSchema } from "./eslint/rules/props-from-schema.js";
10
+ import { requireIslandEditor } from "./eslint/rules/require-island-editor.js";
10
11
  import { serializableIslandProps } from "./eslint/rules/serializable-island-props.js";
11
12
  import { slotMarkerLiteral } from "./eslint/rules/slot-marker-literal.js";
12
13
  import tsParser from "@typescript-eslint/parser";
@@ -17,6 +18,7 @@ const rules = {
17
18
  "no-nondeterminism": noNondeterminism,
18
19
  "no-client-runtime-in-server": noClientRuntimeInServer,
19
20
  "serializable-island-props": serializableIslandProps,
21
+ "require-island-editor": requireIslandEditor,
20
22
  "props-from-schema": propsFromSchema,
21
23
  "no-inline-style": noInlineStyle,
22
24
  "no-raw-element": noRawElement,
@@ -51,6 +53,7 @@ const templateKitConfig = [
51
53
  "template-kit/no-nondeterminism": "error",
52
54
  "template-kit/no-client-runtime-in-server": "error",
53
55
  "template-kit/serializable-island-props": "error",
56
+ "template-kit/require-island-editor": "error",
54
57
  "template-kit/no-inline-style": "error",
55
58
  "template-kit/no-raw-element": "error",
56
59
  "template-kit/image-bare-needs-reason": "error",
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ import { Decision, FillSpec, FillSpecShape } from "./schema/fill-spec.js";
15
15
  import { FixtureModule, FixtureModuleShape, FixtureOverride, ResolvedFixture, buildFixtureSet, buildInvariantFixtures } from "./schema/fixture-schema.js";
16
16
  import { TemplateManifest, TemplateManifestShape } from "./schema/manifest.js";
17
17
  import "./schema/index.js";
18
- import { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ISLAND_ELEMENT, IslandComponent, IslandEditorOptions, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, islandId, serializeIslandProps } from "./islands/contract.js";
18
+ import { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ISLAND_ELEMENT, IslandComponent, IslandEditor, IslandEditorOptions, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, islandId, serializeIslandProps } from "./islands/contract.js";
19
19
  import { IslandMarker, IslandMarkerProps } from "./islands/marker.js";
20
20
  import { Image } from "./primitives/Image.js";
21
21
  import { PicturePlan, PictureSourceSpec } from "./primitives/picture-sources.js";
@@ -39,4 +39,4 @@ import { TokenNameSchema, TokenTheme, TokenThemeSchema, compileThemeToCss } from
39
39
  */
40
40
  declare const KIT_VERSION: string;
41
41
  //#endregion
42
- export { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ATTR_SECTION_INSTANCE_ID, ATTR_SLOT_GROUP, ATTR_SLOT_ID, ATTR_SLOT_ITEM, ATTR_SLOT_TEXT_LEAF, ATTR_TR_ENHANCE, type AddressParts, COLLECTIONS, CONTENT_SLOT_TYPES, type CollectionField, type CollectionIdKey, type CollectionOwnership, ContentSlot, type ContentSlotType, type Coordinates, DECISION_TYPES, DERIVED_INPUT_FIELDS, Decision, type DecisionType, type DerivedInputName, FORMATTER_NAMES, FillSpec, type FillSpecShape, type FixtureModule, FixtureModuleShape, type FixtureOverride, type FloorplanAssignment, type FormatterName, type FrameConfig, ISLAND_ELEMENT, Image, type ImageCollectionValue, type ImageValue, IslandComponent, IslandEditorOptions, IslandMarker, type IslandMarkerProps, IslandModule, IslandProps, IslandPropsError, KIT_VERSION, OptionDef, type OptionValue, type PicturePlan, type PictureSourceSpec, type PlaybackConfig, type PoiRow, type PropertyFacts, REFERRAL_FIELDS, REFERRAL_IMAGE_FIELDS, REF_SLOT_TYPES, type RefSlotType, type ReferralField, type ReferralImageField, type ResolvedFixture, type ResponsiveImage, type ResponsiveSource, SLOT_TYPES, SLOT_TYPE_DESCRIPTORS, SOURCE_FIELDS, Section, SectionMeta, type SectionMetaShape, type SectionNav, type SectionProps, SectionSchema, type SectionSchemaResolved, type SectionSchemaShape, ShieldMode, Slot, SlotDef, type Slot$1 as SlotDefinition, SlotGroup, SlotGroupDef, type SlotGroupDefShape, SlotItem, type SlotType, type SlotValue, type SourceFieldContainer, type SourceFieldDescriptor, type SourceFieldName, type SourceFieldValueType, TRANSFORMS, TRANSFORM_NAMES, TemplateManifest, type TemplateManifestShape, TokenNameSchema, type TokenTheme, TokenThemeSchema, type TransformDescriptor, type TransformFn, type TransformInput, type TransformName, type TransformUnit, type UnitRow, VERIFICATION_WIDTHS, type VariantSelectorFn, type VariantsMeta, type VerificationWidth, Video, type VideoCollectionValue, type VideoValue, type WritebackTarget, buildFixtureSet, buildInvariantFixtures, collectionForSlot, compileThemeToCss, defaultForSlotType, derived, deserializeIslandProps, direct, islandId, makeResolveVariants, serializeIslandProps, slotDefTypes };
42
+ export { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ATTR_SECTION_INSTANCE_ID, ATTR_SLOT_GROUP, ATTR_SLOT_ID, ATTR_SLOT_ITEM, ATTR_SLOT_TEXT_LEAF, ATTR_TR_ENHANCE, type AddressParts, COLLECTIONS, CONTENT_SLOT_TYPES, type CollectionField, type CollectionIdKey, type CollectionOwnership, ContentSlot, type ContentSlotType, type Coordinates, DECISION_TYPES, DERIVED_INPUT_FIELDS, Decision, type DecisionType, type DerivedInputName, FORMATTER_NAMES, FillSpec, type FillSpecShape, type FixtureModule, FixtureModuleShape, type FixtureOverride, type FloorplanAssignment, type FormatterName, type FrameConfig, ISLAND_ELEMENT, Image, type ImageCollectionValue, type ImageValue, IslandComponent, IslandEditor, IslandEditorOptions, IslandMarker, type IslandMarkerProps, IslandModule, IslandProps, IslandPropsError, KIT_VERSION, OptionDef, type OptionValue, type PicturePlan, type PictureSourceSpec, type PlaybackConfig, type PoiRow, type PropertyFacts, REFERRAL_FIELDS, REFERRAL_IMAGE_FIELDS, REF_SLOT_TYPES, type RefSlotType, type ReferralField, type ReferralImageField, type ResolvedFixture, type ResponsiveImage, type ResponsiveSource, SLOT_TYPES, SLOT_TYPE_DESCRIPTORS, SOURCE_FIELDS, Section, SectionMeta, type SectionMetaShape, type SectionNav, type SectionProps, SectionSchema, type SectionSchemaResolved, type SectionSchemaShape, ShieldMode, Slot, SlotDef, type Slot$1 as SlotDefinition, SlotGroup, SlotGroupDef, type SlotGroupDefShape, SlotItem, type SlotType, type SlotValue, type SourceFieldContainer, type SourceFieldDescriptor, type SourceFieldName, type SourceFieldValueType, TRANSFORMS, TRANSFORM_NAMES, TemplateManifest, type TemplateManifestShape, TokenNameSchema, type TokenTheme, TokenThemeSchema, type TransformDescriptor, type TransformFn, type TransformInput, type TransformName, type TransformUnit, type UnitRow, VERIFICATION_WIDTHS, type VariantSelectorFn, type VariantsMeta, type VerificationWidth, Video, type VideoCollectionValue, type VideoValue, type WritebackTarget, buildFixtureSet, buildInvariantFixtures, collectionForSlot, compileThemeToCss, defaultForSlotType, derived, deserializeIslandProps, direct, islandId, makeResolveVariants, serializeIslandProps, slotDefTypes };
@@ -1,4 +1,4 @@
1
- import { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ISLAND_ELEMENT, IslandComponent, IslandEditorOptions, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, islandId, serializeIslandProps } from "./islands/contract.js";
1
+ import { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ISLAND_ELEMENT, IslandComponent, IslandEditor, IslandEditorOptions, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, islandId, serializeIslandProps } from "./islands/contract.js";
2
2
  //#region src/islands/runtime.d.ts
3
3
  /** What the loader knows about one island once its module has loaded. */
4
4
  interface IslandContext {
@@ -27,4 +27,4 @@ declare function hydrateIslands(options: HydrateIslandsOptions): Promise<IslandH
27
27
  */
28
28
  declare function unmountIslands(root?: ParentNode): void;
29
29
  //#endregion
30
- export { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, HydrateIslandsOptions, ISLAND_ELEMENT, IslandComponent, IslandContext, IslandEditorOptions, IslandHandle, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, hydrateIslands, islandId, serializeIslandProps, unmountIslands };
30
+ export { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, HydrateIslandsOptions, ISLAND_ELEMENT, IslandComponent, IslandContext, IslandEditor, IslandEditorOptions, IslandHandle, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, hydrateIslands, islandId, serializeIslandProps, unmountIslands };
@@ -12,10 +12,15 @@ declare const ATTR_ISLAND_PROPS = "data-tr-island-props";
12
12
  type IslandProps = Record<string, unknown>;
13
13
  /** How the editor sizes the transparent selection shield over a live island. */
14
14
  type ShieldMode = "fill-self" | "fill-parent";
15
- /** Optional `export const editor` on a client component: how it behaves inside the editor canvas. */
16
- interface IslandEditorOptions {
15
+ /**
16
+ * What a `"use client"` island must declare about its editor-canvas behavior.
17
+ *
18
+ * An explicit `false` is a decision; a missing field was an accident wearing a
19
+ * decision's clothes.
20
+ */
21
+ interface IslandEditor {
17
22
  /** Hydrate this island in the editor canvas (behind the editor's selection gate). */
18
- live?: boolean;
23
+ live: boolean;
19
24
  /** Shield placement. Default "fill-parent". */
20
25
  shieldMode?: ShieldMode;
21
26
  /** Which element(s) inside the island the shield covers. Omit → the whole island. Runs in the browser. */
@@ -23,6 +28,17 @@ interface IslandEditorOptions {
23
28
  /** Editor-only props, merged over the server-rendered props (e.g. an accordion's single-open canvas mirror). */
24
29
  props?: IslandProps;
25
30
  }
31
+ /**
32
+ * The RUNTIME's tolerant view of `IslandEditor` — every field optional, `live` included.
33
+ *
34
+ * Deliberately not the same type as the authoring contract. `hydrateIslands` reaches a
35
+ * module through a dynamic `import()` and a cast (runtime.ts): it cannot enforce what
36
+ * the module actually exports, and the published-page path never reads `editor` at all.
37
+ * Requiring `live` here would be an assumption the loader has no way to hold. The
38
+ * authoring requirement is enforced where it can be — the `require-island-editor` lint
39
+ * rule and the registry's ingest validator.
40
+ */
41
+ type IslandEditorOptions = Partial<IslandEditor>;
26
42
  /**
27
43
  * A client component's own prop type is the author's business — island props are
28
44
  * validated at runtime (serializeIslandProps), not through this alias, and a
@@ -53,4 +69,4 @@ declare function serializeIslandProps(props: IslandProps): string;
53
69
  /** Read a props payload back. Throws IslandPropsError on anything that is not a JSON object. */
54
70
  declare function deserializeIslandProps(payload: string): IslandProps;
55
71
  //#endregion
56
- export { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ISLAND_ELEMENT, IslandComponent, IslandEditorOptions, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, islandId, serializeIslandProps };
72
+ export { ATTR_ISLAND_ID, ATTR_ISLAND_KEY, ATTR_ISLAND_PROPS, ISLAND_ELEMENT, IslandComponent, IslandEditor, IslandEditorOptions, IslandModule, IslandProps, IslandPropsError, ShieldMode, deserializeIslandProps, islandId, serializeIslandProps };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.7.0";
2
+ var version = "0.8.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/ssr.d.ts CHANGED
@@ -25,7 +25,10 @@ declare const nativeSectionLoader: SectionModuleLoader;
25
25
  * contract files; it defaults to native dynamic import.
26
26
  */
27
27
  declare function loadSectionBundle(dir: string, load?: SectionModuleLoader): Promise<LoadedSection>;
28
- /** Render a resolved fixture's props to static markup — the byte-for-byte page render. */
28
+ /**
29
+ * Render a resolved fixture's props to hydration-ready HTML — the byte-for-byte
30
+ * page render.
31
+ */
29
32
  declare function renderSectionHtml(renderer: ComponentType<Record<string, unknown>>, props: Record<string, unknown>): string;
30
33
  //#endregion
31
34
  export { LoadedSection, SectionModuleLoader, findSectionIslands, hasClientDirective, isClientComponentFile, islandKey, islandTransformEsbuildPlugin, loadSectionBundle, nativeSectionLoader, renderSectionHtml, wrapClientModuleSource };
package/dist/ssr.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { FixtureModuleShape, buildFixtureSet, buildInvariantFixtures } from "./schema/fixture-schema.js";
2
2
  import { hasClientDirective } from "./eslint/is-client-file.js";
3
3
  import { isClientComponentFile, islandKey } from "./islands/detect.js";
4
- import { findSectionIslands } from "./islands/discover.js";
5
4
  import { wrapClientModuleSource } from "./islands/wrap.js";
5
+ import { findSectionIslands } from "./islands/discover.js";
6
6
  import { islandTransformEsbuildPlugin } from "./islands/esbuild-plugin.js";
7
7
  import { createElement } from "react";
8
8
  import { basename, join } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
- import { renderToStaticMarkup } from "react-dom/server";
10
+ import { renderToString } from "react-dom/server";
11
11
 
12
12
  //#region src/ssr/index.ts
13
13
  const nativeSectionLoader = (dir, file) => import(pathToFileURL(join(dir, file)).href);
@@ -37,9 +37,12 @@ async function loadSectionBundle(dir, load = nativeSectionLoader) {
37
37
  schemaSlots: schemaMod.schema.slots
38
38
  };
39
39
  }
40
- /** Render a resolved fixture's props to static markup — the byte-for-byte page render. */
40
+ /**
41
+ * Render a resolved fixture's props to hydration-ready HTML — the byte-for-byte
42
+ * page render.
43
+ */
41
44
  function renderSectionHtml(renderer, props) {
42
- return renderToStaticMarkup(createElement(renderer, props));
45
+ return renderToString(createElement(renderer, props));
43
46
  }
44
47
 
45
48
  //#endregion
package/guide/islands.md CHANGED
@@ -83,20 +83,24 @@ runtime check's job.
83
83
  ## Editor options
84
84
 
85
85
  Templates are edited in a canvas where the section is re-rendered on every keystroke.
86
- An island can declare how it behaves there:
86
+ Every island must declare how it behaves there:
87
+
88
+ - **`live`** — **required**. `true` hydrates the island in the editor canvas behind the
89
+ selection gate; `false` leaves it as static SSR HTML there.
90
+ - **`shieldMode`, `liveSurface`, `props`** — optional, and only meaningful when `live: true`.
87
91
 
88
92
  ```tsx
89
93
  "use client";
90
- import type { IslandEditorOptions } from "@homepages/template-kit";
94
+ import type { IslandEditor } from "@homepages/template-kit";
91
95
 
92
96
  export default function Accordion({ items, singleOpen }: Props) { /* … */ }
93
97
 
94
- export const editor: IslandEditorOptions = {
95
- live: true, // hydrate in the editor canvas (default: false)
98
+ export const editor = {
99
+ live: true, // required — hydrate in the editor canvas
96
100
  shieldMode: "fill-parent", // how the editor sizes its selection shield
97
101
  liveSurface: (root) => [...root.querySelectorAll(".accordion")], // shield only these
98
102
  props: { singleOpen: true }, // editor-only props, merged over the real ones
99
- };
103
+ } satisfies IslandEditor;
100
104
  ```
101
105
 
102
106
  `props` merge in as an update immediately after the island hydrates, not as part of its
@@ -104,8 +108,23 @@ first mount — so an island must read them during render. A value read only at
104
108
  (a `useState` initializer, an `<input defaultValue>`) sees the real prop, and the
105
109
  editor-only override never reaches it.
106
110
 
107
- Omit `editor` entirely and the island simply renders as static markup in the editor —
108
- which is the right choice for most islands.
111
+ There is no default, deliberately. An island that declared nothing used to stay static in
112
+ the canvas, which made "I have not decided" and "static is what I want" the same code. The
113
+ [`require-island-editor`](rules/require-island-editor.md) rule now asks for the answer.
114
+ `live: false` is the safe one if you are unsure — it is what an undeclared island already
115
+ did, written down.
116
+
117
+ The minimal declaration carries just the required field:
118
+
119
+ ```tsx
120
+ "use client";
121
+
122
+ export const editor = { live: true };
123
+
124
+ export default function Lightbox({ headline }: { headline: string }) {
125
+ // …
126
+ }
127
+ ```
109
128
 
110
129
  ## The DOM contract
111
130
 
package/guide/llms.txt CHANGED
@@ -73,6 +73,9 @@ and [Pick a scenario](recipes/pick-a-scenario.md).
73
73
  rendered inside the section.
74
74
  - [README](../README.md): install, exports, and the release flow.
75
75
  - [Islands](islands.md): interactivity — client components, the serializable-props contract, editor options, and the browser loader.
76
+ Every "use client" module with a default export MUST `export const editor` declaring a
77
+ boolean literal `live` — `{ live: true }` hydrates it in the editor canvas, `{ live: false }`
78
+ leaves it static there. There is no default; the require-island-editor rule enforces it.
76
79
  - [Static assets](assets.md): shipping a logo, texture, icon, or brand font with a
77
80
  template — where an asset file lives (template level, or a section folder if the
78
81
  section references it), referencing it from a section's CSS with `url()`/`@font-face`
@@ -2,7 +2,7 @@
2
2
  purpose: Router for the rule corpus — every template-kit/<id> an author can hit, and the page that explains it.
3
3
  status: living
4
4
  related: [server-vs-client.md, ../eslint.md, ../check.md, ../INDEX.md]
5
- updated: 2026-07-15
5
+ updated: 2026-07-17
6
6
  ---
7
7
  # Rules
8
8
 
@@ -25,6 +25,7 @@ and it is explained there rather than on each of them.
25
25
  | [`no-nondeterminism`](no-nondeterminism.md) | eslint | No clock or randomness reads in server-rendered code. |
26
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
27
  | [`serializable-island-props`](serializable-island-props.md) | eslint | Props handed to an island must be JSON-serializable. |
28
+ | [`require-island-editor`](require-island-editor.md) | eslint | Every island must declare whether it is live in the editor canvas. |
28
29
  | [`no-client-directive-in-contract`](no-client-directive-in-contract.md) | eslint | No "use client" in a section's four contract files. |
29
30
  | [`props-from-schema`](props-from-schema.md) | eslint | A Renderer's Props type must be derived from schema.ts, not hand-written. |
30
31
  | [`no-inline-style`](no-inline-style.md) | eslint | No inline style= in server-rendered JSX. Islands are exempt. |
@@ -0,0 +1,101 @@
1
+ ---
2
+ purpose: The template-kit/require-island-editor authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md, ../islands.md]
5
+ updated: 2026-07-17
6
+ ---
7
+ # template-kit/require-island-editor
8
+
9
+ > Every island declares whether it hydrates in the editor canvas. There is no default.
10
+
11
+ ## Rule
12
+
13
+ A file is an island when its directive prologue says `"use client"` **and** it has a
14
+ default export (see [server vs client](server-vs-client.md)). Every island must carry:
15
+
16
+ ```ts
17
+ export const editor = { live: true };
18
+ ```
19
+
20
+ `live` must be written as `true` or `false` literally. A computed value reports.
21
+
22
+ Two files are exempt, and neither is a loophole:
23
+
24
+ - A module with no `"use client"` — it is server-rendered code, not an island.
25
+ - A `"use client"` module with **no default export** — the platform's codemod only turns
26
+ a default-exported client module into an island, so this file never becomes one.
27
+
28
+ ## Reason
29
+
30
+ `live` controls one thing: whether the editor canvas hydrates your island, or leaves it
31
+ as the static HTML the server rendered. Both answers are legitimate. A gallery lightbox
32
+ wants `live: true` so an author can see it behave; a scroll-triggered animation wants
33
+ `live: false` so it does not fire while someone is editing copy.
34
+
35
+ The rule exists because the answer used to be expressed by **saying nothing**. An island
36
+ with no `editor` export simply never hydrated in the canvas — which meant "I have not
37
+ thought about this" and "I want static HTML here" produced identical code. They are not
38
+ the same statement, and the difference is invisible in review.
39
+
40
+ `live` is read **once**, when your module loads. That is why it must be a literal: a
41
+ value computed at runtime cannot change the answer, so a non-literal is never a working
42
+ declaration — only a misleading one.
43
+
44
+ ## Fix
45
+
46
+ Declare it. If you are unsure, `live: false` is the safe answer: it is exactly what your
47
+ island does today, now written down.
48
+
49
+ ### Before
50
+
51
+ ```tsx
52
+ // sections/gallery/Lightbox.tsx
53
+ "use client";
54
+
55
+ import { useState } from "react";
56
+
57
+ export default function Lightbox({ headline }: { headline: string }) {
58
+ const [open, setOpen] = useState(false);
59
+ return (
60
+ <div>
61
+ <button onClick={() => setOpen(true)}>Open gallery</button>
62
+ {open && <p>{headline}</p>}
63
+ </div>
64
+ );
65
+ }
66
+ ```
67
+
68
+ ### After
69
+
70
+ ```tsx
71
+ // sections/gallery/Lightbox.tsx
72
+ "use client";
73
+
74
+ import { useState } from "react";
75
+
76
+ // Authors open the lightbox in the canvas to check it, so hydrate it there.
77
+ export const editor = { live: true };
78
+
79
+ export default function Lightbox({ headline }: { headline: string }) {
80
+ const [open, setOpen] = useState(false);
81
+ return (
82
+ <div>
83
+ <button onClick={() => setOpen(true)}>Open gallery</button>
84
+ {open && <p>{headline}</p>}
85
+ </div>
86
+ );
87
+ }
88
+ ```
89
+
90
+ Type it if you want the editor to check the shape:
91
+
92
+ ```ts
93
+ import type { IslandEditor } from "@homepages/template-kit";
94
+
95
+ export const editor = { live: true } satisfies IslandEditor;
96
+ ```
97
+
98
+ ## See also
99
+
100
+ - [Islands](../islands.md) — the full `editor` contract: `shieldMode`, `liveSurface`, `props`.
101
+ - [Server vs client](server-vs-client.md) — the directive, the prologue, and what an island may do.
package/package.json CHANGED
@@ -1,19 +1,16 @@
1
1
  {
2
2
  "name": "@homepages/template-kit",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",
7
7
  "engines": {
8
8
  "node": ">=20.0.0"
9
9
  },
10
- "workspaces": [
11
- ".",
12
- "create-homepages-workspace"
13
- ],
14
10
  "repository": {
15
11
  "type": "git",
16
- "url": "git+https://github.com/falktravis/template-kit.git"
12
+ "url": "git+https://github.com/falktravis/template-packages.git",
13
+ "directory": "packages/template-kit"
17
14
  },
18
15
  "publishConfig": {
19
16
  "access": "public"
@@ -71,11 +68,7 @@
71
68
  "lint:pkg": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css base.css",
72
69
  "verify:consumer": "node scripts/verify-consumer.mjs",
73
70
  "size:islands": "node scripts/check-island-runtime-size.mjs",
74
- "check": "npm run typecheck && npm run lint && npm run build && npm run size:islands && npm run test && npm run lint:pkg && npm run verify:consumer && npm run check:scaffolder",
75
- "check:scaffolder": "npm run typecheck -w create-homepages-workspace && npm run build -w create-homepages-workspace && npm run test -w create-homepages-workspace",
76
- "changeset": "changeset",
77
- "version": "changeset version && npm install --package-lock-only",
78
- "release": "changeset publish"
71
+ "check": "npm run typecheck && npm run lint && npm run build && npm run size:islands && npm run test && npm run lint:pkg && npm run verify:consumer"
79
72
  },
80
73
  "peerDependencies": {
81
74
  "react": "^19.0.0",
@@ -83,7 +76,6 @@
83
76
  },
84
77
  "devDependencies": {
85
78
  "@arethetypeswrong/cli": "^0.18.5",
86
- "@changesets/cli": "^2.29.8",
87
79
  "@eslint/js": "^9.39.4",
88
80
  "@playwright/test": "^1.60.0",
89
81
  "@tailwindcss/cli": "^4.3.0",