@iterant/site-runtime 3.0.2

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 (36) hide show
  1. package/README.md +30 -0
  2. package/bin/site-runtime.mjs +46 -0
  3. package/docs/runtime-contract.md +324 -0
  4. package/package.json +84 -0
  5. package/scripts/scan-bespoke-siblings.mjs +191 -0
  6. package/scripts/scan-copy.mjs +204 -0
  7. package/scripts/scan-island-imports.mjs +207 -0
  8. package/scripts/verify.mjs +283 -0
  9. package/src/components/seo-json.tsx +157 -0
  10. package/src/components/seo.tsx +294 -0
  11. package/src/config/preset.ts +198 -0
  12. package/src/content/collections.ts +71 -0
  13. package/src/content/schema.ts +239 -0
  14. package/src/index.ts +22 -0
  15. package/src/integrations/iterant-plugins.mjs +83 -0
  16. package/src/integrations/new-file-reload.mjs +95 -0
  17. package/src/integrations/preview-error-shell.mjs +145 -0
  18. package/src/layouts/LayoutCore.astro +182 -0
  19. package/src/layouts/layout-core.ts +141 -0
  20. package/src/lib/bespoke-pages.ts +60 -0
  21. package/src/lib/chrome-schemas.ts +266 -0
  22. package/src/lib/chrome.ts +23 -0
  23. package/src/lib/content-paths.ts +16 -0
  24. package/src/lib/content-values.ts +201 -0
  25. package/src/lib/hreflang.ts +123 -0
  26. package/src/lib/locales.ts +92 -0
  27. package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
  28. package/src/lib/sitemap/index.ts +19 -0
  29. package/src/lib/sitemap/routes.ts +141 -0
  30. package/src/lib/sitemap/shared.ts +95 -0
  31. package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
  32. package/src/routes/UnderConstruction.astro +43 -0
  33. package/src/routes/index.ts +11 -0
  34. package/src/routes/llms-txt.ts +68 -0
  35. package/src/routes/robots-txt.ts +56 -0
  36. package/src/version.ts +9 -0
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @iterant/site-runtime
2
+
3
+ The site runtime maintained by [Iterant](https://iterant.ai), used in every
4
+ website we build and serve: the content grammar and collection schemas, the SEO
5
+ head and JSON-LD graph, the layout core, the Astro config preset, the dev
6
+ integrations, the platform route handlers, and the verify gates.
7
+
8
+ Sites on the Iterant platform install this package and keep only their own
9
+ content, components, and design in their repository; everything mechanical
10
+ lives here and is upgraded by version bump. The contract between the package
11
+ and a site is [`docs/runtime-contract.md`](./docs/runtime-contract.md), which
12
+ ships inside the package so a site always reads the guidance for the exact
13
+ version it is pinned to.
14
+
15
+ ## Published as readable source
16
+
17
+ There is no compiled bundle in this package. What lands in
18
+ `node_modules/@iterant/site-runtime/` are the same `.astro`, `.tsx` and `.ts`
19
+ files we maintain, and a site's own Astro build compiles them together with
20
+ its code. This is deliberate: when something breaks, the stack trace leads to
21
+ a file anyone can open and read. If that file is inside this package, the
22
+ problem is ours; if it is in the site's `src/`, it belongs to the site.
23
+
24
+ ## Intended use
25
+
26
+ This package exists for websites built and operated on the Iterant platform.
27
+ It is published publicly so those sites can install it without credentials,
28
+ and so anyone can read the code their site runs. It is not a general-purpose
29
+ framework, carries no public support commitment, and its API follows the needs
30
+ of the Iterant platform. All rights reserved.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * The site-runtime CLI: the platform's build and content gates, run from a
5
+ * brand repo root.
6
+ *
7
+ * site-runtime verify # the gate: fonts, islands, lint, check, tests, build, bespoke siblings, editor bytes
8
+ * site-runtime scan-copy [files...] # hardcoded visible copy in component code
9
+ * site-runtime scan-island-imports [dir] # island-grade ui primitives imported by registered sections
10
+ * site-runtime scan-bespoke-siblings # bespoke locale-sibling hydration contract (after a build)
11
+ *
12
+ * Every gate resolves paths against process.cwd(), so it must be run from the
13
+ * repo root (`bun run verify`, `npm run verify`, or directly).
14
+ *
15
+ * Each subcommand delegates to the gate in ../scripts/. argv is rewritten so
16
+ * the gate sees exactly what it would see if it had been invoked directly,
17
+ * which is also how the gates stay runnable on their own inside the package.
18
+ */
19
+
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const GATES = {
23
+ verify: "../scripts/verify.mjs",
24
+ "scan-copy": "../scripts/scan-copy.mjs",
25
+ "scan-island-imports": "../scripts/scan-island-imports.mjs",
26
+ "scan-bespoke-siblings": "../scripts/scan-bespoke-siblings.mjs",
27
+ };
28
+
29
+ const [subcommand, ...args] = process.argv.slice(2);
30
+ const gate =
31
+ subcommand && Object.hasOwn(GATES, subcommand)
32
+ ? GATES[/** @type {keyof typeof GATES} */ (subcommand)]
33
+ : undefined;
34
+
35
+ if (!gate) {
36
+ process.stderr.write(
37
+ `site-runtime: unknown command ${subcommand ? `"${subcommand}"` : "(none given)"}\n` +
38
+ `usage: site-runtime <${Object.keys(GATES).join("|")}> [args...]\n`,
39
+ );
40
+ process.exit(2);
41
+ }
42
+
43
+ const gatePath = fileURLToPath(new URL(gate, import.meta.url));
44
+ process.argv = [process.argv[0], gatePath, ...args];
45
+
46
+ await import(gate);
@@ -0,0 +1,324 @@
1
+ # The site runtime contract
2
+
3
+ `@iterant/site-runtime` is the platform layer every Iterant brand site runs on.
4
+ This document is the contract that layer guarantees: the content model, the head
5
+ and structured data rules, the chrome and locale grammar, and the gates that
6
+ enforce them. It ships inside the package, so a repo always reads the guidance
7
+ for the version it is pinned to.
8
+
9
+ A brand repo's own `AGENTS.md` keeps brand notes and points here.
10
+
11
+ ## Ownership: what to edit, what to read
12
+
13
+ | Layer | Owner | Rule |
14
+ | ------------------------------------------------------------------------------------ | -------- | ----------------------- |
15
+ | `src/content/**` (page entries, chrome), sections, page components, `globals.css` | brand | edit freely |
16
+ | `site-config.ts`, `site-shell.ts`, `components/layout/shell.astro`, section registry | brand | edit freely |
17
+ | head + SEO + JSON-LD, layout core, content grammar and collection schemas | platform | read freely, edit never |
18
+ | Astro config preset, dev integrations, verify gates, platform route handlers | platform | read freely, edit never |
19
+
20
+ Platform code sits at `node_modules/@iterant/site-runtime/` as plain source, so
21
+ a stack trace points at a legible file and any tool that reads `src/lib/` reads
22
+ this too. That is deliberate: reading is how you tell whose bug it is. A trace
23
+ into `node_modules/@iterant/site-runtime` means a platform bug, so report it and
24
+ pin or bump; a trace into `src/` means this repo's code.
25
+
26
+ Editing platform code in place is what the package exists to stop. One patched
27
+ Layout forks that brand forever and it stops receiving fixes. When a brand truly
28
+ needs different behavior, eject the shim instead: paste the file back into the
29
+ repo and mark it, the same convention a replaced `shell.astro` uses. When the
30
+ defect is in the package, say so.
31
+
32
+ ## Content: copy lives in JSON, never in code
33
+
34
+ No visible string is ever hardcoded in a component. Every heading, paragraph,
35
+ link label, button label and image alt on every page lives in that page's JSON
36
+ entry (`src/content/pages/<page>.json`); navbar and footer copy lives in
37
+ `src/content/chrome.json`. Components receive content as typed props and render
38
+ it. That is what makes every page editable in the visual editor and translatable
39
+ without touching code.
40
+
41
+ The grammar (`content-values.ts`, schema-enforced):
42
+
43
+ - Copy is **wrapped**: `{"type":"text","value":"…"}`,
44
+ `{"type":"link","text":"…","href":"…"}`,
45
+ `{"type":"image","src":"…","alt":"…"}`, `{"type":"svg","markup":"…"}`,
46
+ `{"type":"color","value":"…"}`. Repeated content is
47
+ `{"type":"array","items":[{…}]}`.
48
+ - Non-copy config stays bare: numbers, booleans, short lowercase tokens
49
+ (`"zap"`, `"center"`, `"inverse"`). Bare strings with uppercase letters or
50
+ spaces are rejected by the schema, so wrap them.
51
+ - An image wrapper may carry responsive `srcset` candidates, each a `src` plus a
52
+ width (`"400w"`) or density (`"2x"`) descriptor.
53
+ - Every component has a stable kebab-case `id`, unique per entry, assigned once
54
+ and NEVER renamed: edits and translations are addressed through it.
55
+ - Components render bindings so tools can find the copy in the DOM:
56
+ `data-component={id}` plus `data-component-type` on the section root;
57
+ `data-editable="<field>"`, `data-path="props.<field>"` and
58
+ `data-edit-type="text|link|image|color"` on each copy-bearing element. Paths
59
+ are wrapper-level (`props.heading`, `props.features.0.title`), never
60
+ leaf-level. Array containers carry `data-array-container="<field>"`, items
61
+ `data-array-item={index}`.
62
+ - Rendering may transform copy for presentation (highlighting a word, splitting
63
+ on a separator) only with operands that come from the entry: add a field for
64
+ the term and use it. Never a literal in TSX, and never "correct" entry copy in
65
+ code: what looks like a typo may be brand voice or deliberate test data.
66
+ - `site-runtime scan-copy` finds hardcoded copy in component code. Like every
67
+ gate, never edit or weaken it.
68
+
69
+ ### The collection schemas
70
+
71
+ The repo's `src/content.config.ts` is a shim over `createCollections`, which
72
+ takes the two brand-owned registries and returns the `pages` and `chrome`
73
+ collections:
74
+
75
+ ```ts
76
+ import { createCollections } from "@iterant/site-runtime/content";
77
+ import { CHROME_COMPONENT_PROPS } from "@iterant/site-runtime/chrome-schemas";
78
+ import { REGISTERED_SECTION_PROPS } from "./lib/section-schemas";
79
+
80
+ export const collections = createCollections({
81
+ registeredSectionProps: REGISTERED_SECTION_PROPS,
82
+ chromeComponentProps: CHROME_COMPONENT_PROPS,
83
+ });
84
+ ```
85
+
86
+ WHICH section types exist is brand-owned. The grammar around them is not: entry
87
+ shape, id rules, uniqueness, the registry check on `mode: "registry"` pages, the
88
+ chrome mount contract, locale entry ids and `_translation` all live in the
89
+ package and change with a version bump.
90
+
91
+ ## Pages: entry-driven vs bespoke
92
+
93
+ Every page has a JSON entry holding its route, SEO meta and content. What
94
+ differs is who renders it.
95
+
96
+ **Entry-driven** (`"mode": "registry"`, the default): the catch-all
97
+ `src/pages/[...slug].astro` resolves the request path against each entry's
98
+ `route` at request time and renders its `components` through the repo's section
99
+ registry. Cheap to create, cheap to edit, schema-validated. Use it for pages
100
+ that are a linear stack of known section types.
101
+
102
+ Registered sections are server-rendered, never hydrated: Astro cannot statically
103
+ trace `client:*` directives through a registry map, so adding one renders
104
+ nothing. Keep section components presentational. A section that needs client JS
105
+ is a signal the page should be bespoke instead, and the island-import gate fails
106
+ the build on an island-grade primitive imported under
107
+ `src/components/sections/`.
108
+
109
+ **Bespoke** (real code): set `"mode": "bespoke"` in the entry, create the React
110
+ page at `src/components/pages/<page>/page.tsx`, and a thin Astro shell at
111
+ `src/pages/<page>.astro` that loads the entry and threads it down. Static routes
112
+ win over the catch-all automatically. The COPY still comes from the entry,
113
+ always.
114
+
115
+ Write in this order: entry JSON, then `page.tsx`, then the route shell LAST. The
116
+ route goes live the moment the shell lands. While a route is broken mid-edit the
117
+ dev server swaps the error page for `/under-construction`, which is a platform
118
+ route: never delete, rename, or link to it.
119
+
120
+ Two conventions make a bespoke page's locale siblings hydrate, and both break
121
+ silently: the page component lives at `src/components/pages/<page>/page.tsx` as
122
+ the module's default or sole function export, and the shell hydrates it with
123
+ `client:load` importing from `@/components/pages/<page>/page`. That static import
124
+ is what puts the component in the client bundle siblings hydrate from. The
125
+ bespoke-sibling gate enforces both on every non-draft bespoke page, after the
126
+ build.
127
+
128
+ ## Structured data is derived, never authored
129
+
130
+ Every indexable page emits a schema.org graph (Organization, WebSite, the page
131
+ node, BreadcrumbList, plus a Product node on product pages) built
132
+ deterministically from the entry meta and the canonical URL. Your only job is to
133
+ declare what the page IS:
134
+
135
+ ```json
136
+ "meta": { "pageType": "article", "datePublished": "2026-08-09" }
137
+ ```
138
+
139
+ Values: `article` (blog posts, guides, case studies; set `datePublished`, and
140
+ touch `dateModified` on substantial updates), `product` (product and pricing
141
+ pages), `about`, `contact`, or omit for a plain WebPage.
142
+
143
+ The layout resolves the entry by route itself, so neither the catch-all nor a
144
+ bespoke shell threads any of this. Do not hand-write JSON-LD for types this
145
+ covers. For a genuinely custom schema (Service, Event, FAQ) pass a typed object
146
+ through the layout's `jsonLd` prop or render `SeoJson` beside the visible content
147
+ it describes; it merges into the graph and overrides any default node sharing its
148
+ `@type`.
149
+
150
+ ## The layout
151
+
152
+ `src/layouts/Layout.astro` in the repo is a shim over
153
+ `@iterant/site-runtime/layout`. It injects the three brand-owned values and
154
+ imports `globals.css`:
155
+
156
+ ```astro
157
+ ---
158
+ import LayoutCore from "@iterant/site-runtime/layout";
159
+ import "../styles/globals.css";
160
+ import Shell from "@/components/layout/shell.astro";
161
+ import { SITE_CONFIG } from "@/site-config";
162
+ import { SITE_SHELL } from "@/site-shell";
163
+ ---
164
+
165
+ <LayoutCore
166
+ {...Astro.props}
167
+ siteConfig={SITE_CONFIG}
168
+ siteShell={SITE_SHELL}
169
+ Shell={Shell}
170
+ >
171
+ <slot name="head" slot="head" />
172
+ <slot />
173
+ </LayoutCore>
174
+ ```
175
+
176
+ Every shell keeps importing `../layouts/Layout.astro` unchanged. The core owns
177
+ the head (charset, viewport, favicon, generator, version meta, SEO, JSON-LD,
178
+ hreflang, the `head` slot), resolves locale-aware chrome and the page entry, and
179
+ renders the brand shell around the page slot with the `chrome`, `navbar` and
180
+ `footer` props. It never imports a stylesheet and never imports chrome
181
+ components: `shell.astro` mounts those, which is what keeps their `client:load`
182
+ directive traceable.
183
+
184
+ ### The one `@source` line
185
+
186
+ Tailwind v4 needs a `@source` line for anything outside the repo it should scan
187
+ for class names. Point it at ONE file:
188
+
189
+ ```css
190
+ @source "../../node_modules/@iterant/site-runtime/src/routes/UnderConstruction.astro";
191
+ ```
192
+
193
+ That is the only shipped file with real utility classes, and the package's own
194
+ suite fails if a second one appears. Do not point `@source` at the whole
195
+ `src` tree: Tailwind's extractor cannot tell prose from markup, so every
196
+ utility-shaped word in a comment or a validation message (`lowercase`,
197
+ `sticky`, `transition`) becomes a real rule in every page's stylesheet.
198
+
199
+ ## Chrome (navbar and footer)
200
+
201
+ Navbar and footer are structural, built once per brand, rendered on every page,
202
+ but their copy lives in `src/content/chrome.json` under the same component
203
+ grammar, one component per piece (`"id": "navbar"`, `"id": "footer"`). The layout
204
+ mounts ONLY those two ids, resolved by id, so an absent component renders no
205
+ chrome at all. Any other id in `chrome.json` is a hard schema error rather than a
206
+ silent no-op: an announcement or utility bar belongs in a page section adjacent
207
+ to the chrome. A component whose `type` does not match its `id` is also an error,
208
+ because it validates the wrong props and renders nothing. Clone-emitted chrome is
209
+ the one exception: `type: "replicated"` on a known id carries its copy in bespoke
210
+ props.
211
+
212
+ A locale-prefixed request mounts `chrome.<locale>.json` when that sibling exists
213
+ and falls back to the base chrome otherwise, because pages translate before
214
+ chrome does and a missing sibling must never strip the nav.
215
+
216
+ ## Locales
217
+
218
+ A translated page is a first-class entry next to its base:
219
+ `src/content/pages/<page>.<locale>.json`, with the lowercased locale as both the
220
+ filename suffix and the route prefix (`/es/pricing`). The unsuffixed base stays
221
+ the x-default. Chrome follows the same model.
222
+
223
+ The shell emits `<html lang>` and the reciprocal hreflang set by threading
224
+ `pageLocaleHead`. Every non-draft entry in a group contributes one alternate plus
225
+ exactly one `x-default` pointing at the base; a page with no siblings emits
226
+ nothing, and draft siblings are never advertised. A bespoke page's locale
227
+ siblings need no per-page work: the catch-all renders them through the base's
228
+ page component with the sibling's translated `components`.
229
+
230
+ ## Sitemap and platform routes
231
+
232
+ `sitemap-index.xml` is generated at build by the preset's sitemap wiring.
233
+ Prerendered pages are included automatically; SSR routes are invisible to
234
+ `@astrojs/sitemap`, so their paths are read from the page entries on disk (each
235
+ non-draft entry's `route`), resolved against Astro's own project root at
236
+ `astro:config:setup`. A build driven from any directory therefore lists the same
237
+ routes, and a page directory that reads empty is logged rather than silently
238
+ dropped. The `site` value in `astro.config.mjs` is patched at deploy time by the
239
+ platform and must remain a string literal, so the sitemap helper emits against a
240
+ placeholder host and swaps it at emit time.
241
+
242
+ Platform routes stay VISIBLE files in `src/pages/`, each a shim over a package
243
+ handler. Nothing is injected: existence and addressing stay repo-owned so the
244
+ `src/pages/` mental model holds, while the behavior rides the bump. A repo
245
+ carries FOUR of them, all four unconditionally:
246
+
247
+ | File | Handler | `prerender` |
248
+ | -------------------------------------- | --------------------------- | ----------- |
249
+ | `src/pages/llms.txt.ts` | `createLlmsTxtRoute` | `true` |
250
+ | `src/pages/robots.txt.ts` | `createRobotsTxtRoute` | `true` |
251
+ | `src/pages/sitemap.xml.ts` | `createSitemapRoute` | `false` |
252
+ | `src/pages/proxied-sitemap-[i].xml.ts` | `createProxiedSitemapRoute` | `false` |
253
+
254
+ The two sitemap-proxy files are the ones easy to skip, and skipping them is how a
255
+ brand loses its mirrored sitemap: they carry no logic of their own, so a repo
256
+ cannot tell from its own tree that `sourceSitemapUrl` has nowhere to be served
257
+ from. Both handlers answer 404 when no upstream is configured, which is what the
258
+ routes did when they existed only for brands that had one. That 404 is a plain
259
+ `text/plain` body from the handler rather than the branded 404 page a missing
260
+ route would have rendered, the one intentional difference.
261
+
262
+ `src/pages/under-construction.astro` is the fifth platform route, a three-line
263
+ shim over the package's `UnderConstruction` component (see The layout).
264
+
265
+ ## The config preset
266
+
267
+ `astro.config.mjs` keeps only per-site values and spreads the preset:
268
+
269
+ ```js
270
+ export default defineConfig({
271
+ // Patched at deploy time by the platform: must remain a string literal.
272
+ site: "https://example.com",
273
+ ...iterantStarter(),
274
+ });
275
+ ```
276
+
277
+ The preset carries the adapter, the integrations (React, sitemap, and the
278
+ dev-only plugin loader, preview error shell and new-file reload), the vite
279
+ tuning, the dev server port and host, and the toolbar setting. A diverged repo
280
+ extends rather than ejects: extra integrations and vite plugins append through
281
+ `overrides`, and any other key it needs it writes in its own `defineConfig`
282
+ object after the spread.
283
+
284
+ ## Verify: the gate
285
+
286
+ `bun run verify` maps to `site-runtime verify` and is the gate. It is silent on
287
+ success and prints the failing step's full output on failure. If it fails, the
288
+ change is broken. In order:
289
+
290
+ 1. **font integrity**: every `public/fonts/*.woff2` is a real WOFF2 whose header
291
+ size matches the file, and every local `@font-face` URL resolves. Corrupt
292
+ fonts fail silently at runtime, with a green build.
293
+ 2. **island imports**: no island-grade ui primitive is imported under
294
+ `src/components/sections/`.
295
+ 3. **eslint**, then **astro check**, then the repo's own unit tests, then
296
+ **astro build**.
297
+ 4. **bespoke siblings**: every non-draft bespoke page satisfies the two
298
+ hydration conventions, checked against the fresh `dist/`, plus a pin on the
299
+ Astro runtime directive contract they rely on.
300
+ 5. **editor bytes**: a production build contains zero visual-editor bytes.
301
+
302
+ Other subcommands: `site-runtime scan-copy [files...]`,
303
+ `site-runtime scan-island-imports [dir]`, `site-runtime scan-bespoke-siblings`.
304
+ All of them resolve paths against the current working directory, so run them
305
+ from the repo root.
306
+
307
+ New gates arrive advisory. A bump may land a check in warn mode and record the
308
+ debt for the brand's next session rather than failing a fleet of builds at once;
309
+ a gate is promoted to failing only in a later major.
310
+
311
+ ## Version identity
312
+
313
+ The installed package version IS the site's runtime version. Every page emits
314
+ `<meta name="it-site-runtime" content="<version>">`, read straight from the
315
+ package, so there is nothing to re-stamp on a bump.
316
+ `<meta name="it-astro-starter-version">` keeps emitting the same value for now,
317
+ because the plugin loader and the platform's page indexer still read that name.
318
+
319
+ One vestigial file rides along with it: a repo keeps `src/starter-version.ts`
320
+ exporting the same version string (`"3.0.0"`) until the platform agent reads the
321
+ package pin instead. The agent's capability resolution treats a MISSING
322
+ `starter-version.ts` as the oldest possible starter and degrades accordingly, so
323
+ deleting the file early costs a brand its newest capabilities. Nothing in the
324
+ package reads it, and it goes away with that read.
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@iterant/site-runtime",
3
+ "version": "3.0.2",
4
+ "type": "module",
5
+ "description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
6
+ "scripts": {
7
+ "clean": "git clean -xdf .cache .turbo node_modules fixtures",
8
+ "format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,mdx}\" --ignore-path=\"../../.prettierignore\"",
9
+ "format:fix": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,mdx}\" --ignore-path=\"../../.prettierignore\"",
10
+ "lint": "eslint . --max-warnings 0",
11
+ "lint:fix": "eslint --fix .",
12
+ "test": "vitest run && node scripts/check-fixture.mjs",
13
+ "test:fixture": "node scripts/check-fixture.mjs",
14
+ "test:packed": "node scripts/check-packed.mjs",
15
+ "test:unit": "vitest run",
16
+ "test:watch": "vitest",
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "bin": {
20
+ "site-runtime": "./bin/site-runtime.mjs"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "docs",
25
+ "scripts",
26
+ "src",
27
+ "!**/*.test.ts",
28
+ "!scripts/check-fixture.mjs",
29
+ "!scripts/check-packed.mjs",
30
+ "!src/lib/__fixtures__"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts",
34
+ "./content": "./src/content/collections.ts",
35
+ "./content/schema": "./src/content/schema.ts",
36
+ "./content-values": "./src/lib/content-values.ts",
37
+ "./locales": "./src/lib/locales.ts",
38
+ "./hreflang": "./src/lib/hreflang.ts",
39
+ "./chrome": "./src/lib/chrome.ts",
40
+ "./chrome-schemas": "./src/lib/chrome-schemas.ts",
41
+ "./bespoke-pages": "./src/lib/bespoke-pages.ts",
42
+ "./sitemap": "./src/lib/sitemap/index.ts",
43
+ "./seo": "./src/components/seo.tsx",
44
+ "./seo-json": "./src/components/seo-json.tsx",
45
+ "./layout": "./src/layouts/LayoutCore.astro",
46
+ "./under-construction": "./src/routes/UnderConstruction.astro",
47
+ "./routes": "./src/routes/index.ts",
48
+ "./config": "./src/config/preset.ts",
49
+ "./integrations/*": "./src/integrations/*.mjs",
50
+ "./package.json": "./package.json"
51
+ },
52
+ "dependencies": {
53
+ "@astrojs/cloudflare": "^13.1.10",
54
+ "@astrojs/react": "^5.0.3",
55
+ "@astrojs/sitemap": "^3.7.2",
56
+ "schema-dts": "^2.0.0"
57
+ },
58
+ "peerDependencies": {
59
+ "@tailwindcss/vite": "^4.1.18",
60
+ "astro": "^6.1.8",
61
+ "react": "^19.2.3",
62
+ "react-dom": "^19.2.3",
63
+ "tailwindcss": "^4.1.18",
64
+ "typescript": ">=5.6"
65
+ },
66
+ "devDependencies": {
67
+ "@astrojs/check": "^0.9.8",
68
+ "@tailwindcss/vite": "4.2.2",
69
+ "@types/node": "24.3.1",
70
+ "@types/react": "19.2.14",
71
+ "@types/react-dom": "19.2.3",
72
+ "@workspace/eslint-config": "workspace:*",
73
+ "@workspace/typescript-config": "workspace:*",
74
+ "astro": "^6.1.8",
75
+ "react": "19.2.4",
76
+ "react-dom": "19.2.4",
77
+ "tailwindcss": "4.2.2",
78
+ "typescript": "5.9.2",
79
+ "vitest": "^4.0.13",
80
+ "wrangler": "^4.107.0"
81
+ },
82
+ "license": "UNLICENSED",
83
+ "homepage": "https://iterant.ai"
84
+ }
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * Bespoke-sibling hydration gate (starter 2.10.0 / PSI-B.5). Runs AFTER
5
+ * `astro build` (verify.mjs sequences it) against the fresh dist/.
6
+ *
7
+ * A bespoke page's locale siblings (`home.es.json`, route `/es`) render
8
+ * through the catch-all, which hydrates the base's page component by passing
9
+ * the shell's import specifier (`@/components/pages/<base>/page`) as
10
+ * `client:component-path`. Two conventions make that work, and BOTH break
11
+ * SILENTLY at runtime — an unhydrated sibling, or a base-renders/sibling-404s
12
+ * export mismatch — with zero build signal:
13
+ *
14
+ * 1. the shell hydrates the page component with `client:load` from
15
+ * `@/components/pages/<base>/page`: that static import is what puts the
16
+ * component in the client bundle, keyed by that exact specifier in the
17
+ * built SSR manifest's entryModules;
18
+ * 2. `page.tsx` exposes the page as its default or sole function export —
19
+ * the shape the catch-all's pickPageExport (src/lib/bespoke-pages.ts)
20
+ * resolves.
21
+ *
22
+ * For every NON-DRAFT bespoke base entry, this asserts (1) against the built
23
+ * manifest and (2) by importing the built client entry itself. Draft bespoke
24
+ * pages are exempt: drafts never publish, their siblings are never advertised
25
+ * (hreflang/sitemap), and the stock `not-found` page is a draft that
26
+ * deliberately lives outside the `<base>/page.tsx` convention (404.astro
27
+ * renders it). Publishing a bespoke page puts it in scope on the next verify.
28
+ *
29
+ * It also pins the Astro runtime contract the catch-all relies on: the island
30
+ * renderer must keep accepting `client:component-path` /
31
+ * `client:component-export` as props (astro/dist/runtime/server/hydration.js
32
+ * `extractDirectives`). An Astro upgrade that changes directive handling
33
+ * turns this gate red instead of silently shipping unhydratable siblings.
34
+ *
35
+ * Silent on success; problems go to stderr with exit 1 (verify.mjs surfaces
36
+ * the buffer on failure). Like verify.mjs and scan-copy.mjs, never edit or
37
+ * weaken this script.
38
+ */
39
+
40
+ import { readdir, readFile } from "node:fs/promises";
41
+ import { join } from "node:path";
42
+ import { pathToFileURL } from "node:url";
43
+ import { exit } from "node:process";
44
+
45
+ const CONVENTIONS =
46
+ "bespoke pages must keep their page component at " +
47
+ "src/components/pages/<page>/page.tsx (default or sole function export) " +
48
+ "and their shell must render it with `client:load` importing from " +
49
+ '"@/components/pages/<page>/page" — see AGENTS.md "Pages: entry-driven vs bespoke"';
50
+
51
+ /** @type {string[]} */
52
+ const problems = [];
53
+
54
+ // ---- SF-pin: the Astro runtime directive contract --------------------------
55
+
56
+ const hydrationJs = join(
57
+ process.cwd(),
58
+ "node_modules/astro/dist/runtime/server/hydration.js",
59
+ );
60
+ const hydrationSrc = await readFile(hydrationJs, "utf8").catch(() => "");
61
+ for (const directive of ["client:component-path", "client:component-export"]) {
62
+ if (!hydrationSrc.includes(`case "${directive}"`)) {
63
+ problems.push(
64
+ `astro runtime contract changed: ${hydrationJs} no longer handles ` +
65
+ `"${directive}" as a prop. Bespoke locale siblings hydrate through ` +
66
+ `exactly that escape hatch (src/pages/[...slug].astro) — re-verify ` +
67
+ `sibling hydration against this Astro version before shipping.`,
68
+ );
69
+ }
70
+ }
71
+
72
+ // ---- SF1+SF2: every non-draft bespoke base ---------------------------------
73
+
74
+ /** Non-draft bespoke base entry ids (`home`), never locale siblings. */
75
+ async function bespokeBases() {
76
+ const pagesDir = join(process.cwd(), "src/content/pages");
77
+ /** @type {string[]} */
78
+ const bases = [];
79
+ /** @type {string[]} */
80
+ let names = [];
81
+ try {
82
+ names = await readdir(pagesDir);
83
+ } catch {
84
+ return bases; // no pages dir — astro check owns that failure
85
+ }
86
+ for (const name of names.filter((n) => n.endsWith(".json")).sort()) {
87
+ const id = name.replace(/\.json$/, "");
88
+ if (id.includes(".")) continue; // locale sibling — guarded via its base
89
+ /** @type {{ mode?: string; draft?: boolean }} */
90
+ let entry;
91
+ try {
92
+ entry = JSON.parse(await readFile(join(pagesDir, name), "utf8"));
93
+ } catch {
94
+ continue; // unparseable JSON is astro check's failure, not ours
95
+ }
96
+ if (entry.mode === "bespoke" && entry.draft !== true) bases.push(id);
97
+ }
98
+ return bases;
99
+ }
100
+
101
+ /**
102
+ * The built SSR manifest's entryModules map (import specifier → client
103
+ * asset). Serialized inside one of the server chunks; values are plain
104
+ * strings, so the object literal is brace-free and parses as JSON.
105
+ * @returns {Promise<Record<string, string> | null>}
106
+ */
107
+ async function builtEntryModules() {
108
+ const serverDir = join(process.cwd(), "dist/server");
109
+ /** @type {string[]} */
110
+ const stack = [serverDir];
111
+ while (stack.length > 0) {
112
+ const dir = stack.pop();
113
+ if (!dir) break;
114
+ /** @type {import("node:fs").Dirent[]} */
115
+ let entries = [];
116
+ try {
117
+ entries = await readdir(dir, { withFileTypes: true });
118
+ } catch {
119
+ continue;
120
+ }
121
+ for (const entry of entries) {
122
+ const path = join(dir, entry.name);
123
+ if (entry.isDirectory()) {
124
+ stack.push(path);
125
+ continue;
126
+ }
127
+ if (!entry.name.endsWith(".mjs")) continue;
128
+ const content = await readFile(path, "utf8");
129
+ const match = /"entryModules":(\{[^{}]*\})/.exec(content);
130
+ if (match) return JSON.parse(match[1]);
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ const bases = await bespokeBases();
137
+ if (bases.length > 0 && problems.length === 0) {
138
+ const entryModules = await builtEntryModules();
139
+ if (!entryModules) {
140
+ problems.push(
141
+ "could not locate the entryModules manifest in dist/server — run this " +
142
+ "gate after `astro build` (verify.mjs does), and if the build layout " +
143
+ "changed, update scan-bespoke-siblings.mjs to match.",
144
+ );
145
+ } else {
146
+ for (const base of bases) {
147
+ const specifier = `@/components/pages/${base}/page`;
148
+ const asset = entryModules[specifier];
149
+ if (!asset) {
150
+ problems.push(
151
+ `bespoke page "${base}": "${specifier}" is not a client entry in ` +
152
+ `the built manifest, so its locale siblings would render ` +
153
+ `UNHYDRATED (dead buttons, no client JS) — ${CONVENTIONS}.`,
154
+ );
155
+ continue;
156
+ }
157
+ // Import the built client entry — the same module the sibling's island
158
+ // loads in the browser — and hold it to pickPageExport's shape.
159
+ const assetPath = join(process.cwd(), "dist/client", asset.split("?")[0]);
160
+ /** @type {Record<string, unknown>} */
161
+ let mod;
162
+ try {
163
+ mod = await import(pathToFileURL(assetPath).href);
164
+ } catch (err) {
165
+ problems.push(
166
+ `bespoke page "${base}": built client entry ${asset} failed to ` +
167
+ `import (${err instanceof Error ? err.message : err}) — its ` +
168
+ `locale siblings could not hydrate.`,
169
+ );
170
+ continue;
171
+ }
172
+ const fns = Object.values(mod).filter((v) => typeof v === "function");
173
+ if (typeof mod.default !== "function" && fns.length !== 1) {
174
+ problems.push(
175
+ `bespoke page "${base}": src/components/pages/${base}/page.tsx ` +
176
+ `exports ${fns.length} functions and no default — the catch-all ` +
177
+ `cannot pick the page component, so its locale siblings would ` +
178
+ `404 while the base page keeps working — ${CONVENTIONS}.`,
179
+ );
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ if (problems.length > 0) {
186
+ process.stderr.write(
187
+ `bespoke-sibling gate failed:\n${problems.join("\n")}\n`,
188
+ );
189
+ exit(1);
190
+ }
191
+ exit(0);