@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.
- package/README.md +30 -0
- package/bin/site-runtime.mjs +46 -0
- package/docs/runtime-contract.md +324 -0
- package/package.json +84 -0
- package/scripts/scan-bespoke-siblings.mjs +191 -0
- package/scripts/scan-copy.mjs +204 -0
- package/scripts/scan-island-imports.mjs +207 -0
- package/scripts/verify.mjs +283 -0
- package/src/components/seo-json.tsx +157 -0
- package/src/components/seo.tsx +294 -0
- package/src/config/preset.ts +198 -0
- package/src/content/collections.ts +71 -0
- package/src/content/schema.ts +239 -0
- package/src/index.ts +22 -0
- package/src/integrations/iterant-plugins.mjs +83 -0
- package/src/integrations/new-file-reload.mjs +95 -0
- package/src/integrations/preview-error-shell.mjs +145 -0
- package/src/layouts/LayoutCore.astro +182 -0
- package/src/layouts/layout-core.ts +141 -0
- package/src/lib/bespoke-pages.ts +60 -0
- package/src/lib/chrome-schemas.ts +266 -0
- package/src/lib/chrome.ts +23 -0
- package/src/lib/content-paths.ts +16 -0
- package/src/lib/content-values.ts +201 -0
- package/src/lib/hreflang.ts +123 -0
- package/src/lib/locales.ts +92 -0
- package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
- package/src/lib/sitemap/index.ts +19 -0
- package/src/lib/sitemap/routes.ts +141 -0
- package/src/lib/sitemap/shared.ts +95 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
- package/src/routes/UnderConstruction.astro +43 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/llms-txt.ts +68 -0
- package/src/routes/robots-txt.ts +56 -0
- 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);
|