@iterant/site-runtime 3.0.2 → 3.1.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.
@@ -29,6 +29,63 @@ needs different behavior, eject the shim instead: paste the file back into the
29
29
  repo and mark it, the same convention a replaced `shell.astro` uses. When the
30
30
  defect is in the package, say so.
31
31
 
32
+ ## Available libraries
33
+
34
+ This package installs the toolchain and a curated kit, so a repo's own
35
+ `package.json` is nearly empty and every entry in it reads as a deliberate
36
+ divergence from a platform default.
37
+
38
+ **Import kit libraries directly. Declare one in `package.json` ONLY to override
39
+ the platform version.** `import { ArrowRight } from "lucide-react"` works with
40
+ nothing added to the manifest; the copy comes from this package. Adding your own
41
+ entry wins node resolution, which is exactly how an override is expressed, and it
42
+ also means platform bumps stop moving that library: `site-runtime verify` reports
43
+ every override it finds, and never fails on one.
44
+
45
+ The toolchain is different in one way: it is NOT overridable. A repo that
46
+ declares `astro`, `react` or `tailwindcss` installs a second copy, and then
47
+ `astro build` runs one version while these gates check another. Verify fails on
48
+ such a declaration. A repo that truly needs its own toolchain version ejects the
49
+ runtime and says so.
50
+
51
+ <!-- generated: available libraries -->
52
+
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.0._
54
+
55
+ **Toolchain** (this package owns the version; do NOT declare these):
56
+
57
+ | Package | Version | What it is |
58
+ | --------------------- | ------- | ------------------------------------------------ |
59
+ | `astro` | 7.2.0 | the framework: routing, content collections, SSR |
60
+ | `react` | 19.2.8 | islands and bespoke page components |
61
+ | `react-dom` | 19.2.8 | React's DOM renderer |
62
+ | `tailwindcss` | 4.3.3 | the utility CSS engine |
63
+ | `@tailwindcss/vite` | 4.3.3 | Tailwind's build integration |
64
+ | `@astrojs/cloudflare` | 14.2.0 | the Workers adapter |
65
+ | `@astrojs/react` | 6.0.2 | the React renderer integration |
66
+ | `@astrojs/sitemap` | 3.7.3 | sitemap generation |
67
+ | `@astrojs/check` | 0.9.10 | the type checker `astro check` runs |
68
+
69
+ **Curated kit** (import directly; declare only to override):
70
+
71
+ | Package | Version | What it is |
72
+ | -------------------------- | ------- | ------------------------------------------- |
73
+ | `lucide-react` | 1.31.0 | icons |
74
+ | `motion` | 13.0.0 | animation |
75
+ | `radix-ui` | 1.6.7 | unstyled accessible primitives |
76
+ | `@radix-ui/react-slot` | 1.3.3 | prop forwarding for composable components |
77
+ | `embla-carousel-react` | 8.6.0 | carousels |
78
+ | `clsx` | 2.1.1 | conditional class names |
79
+ | `tailwind-merge` | 3.6.0 | conflicting-utility resolution |
80
+ | `class-variance-authority` | 0.7.1 | component variants |
81
+ | `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped |
82
+ | `github-slugger` | 2.0.0 | heading and anchor slugs |
83
+
84
+ <!-- /generated -->
85
+
86
+ Anything else a page genuinely needs is a normal repo dependency, invisible to
87
+ the platform: install it, declare it, own it.
88
+
32
89
  ## Content: copy lives in JSON, never in code
33
90
 
34
91
  No visible string is ever hardcoded in a component. Every heading, paragraph,
@@ -276,28 +333,55 @@ export default defineConfig({
276
333
 
277
334
  The preset carries the adapter, the integrations (React, sitemap, and the
278
335
  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
336
+ tuning, the CSS browser floor, the dev server port and host, and the toolbar
337
+ setting. A diverged repo
280
338
  extends rather than ejects: extra integrations and vite plugins append through
281
339
  `overrides`, and any other key it needs it writes in its own `defineConfig`
282
340
  object after the spread.
283
341
 
342
+ ### The browser floor
343
+
344
+ The preset declares the browsers every brand site's CSS is compiled for, to
345
+ Lightning CSS (Vite's minifier): **Safari 16.4, Chrome 111, Firefox 128**. That is
346
+ Tailwind v4's own floor, and the floor the emitted stylesheet already has, since
347
+ it ships `@property` declarations and `color-mix()` calls with no fallbacks. Stated
348
+ rather than inherited, because with no targets the minifier leaves whatever syntax
349
+ Tailwind emitted: breakpoints go out as `@media (width>=40rem)`, which older
350
+ engines do not parse, so every breakpoint in the sheet silently stops applying on
351
+ them.
352
+
353
+ A repo must not set `build.cssTarget` itself. Wanting older browsers is a platform
354
+ conversation: lowering the floor to Safari 15 does restore `min-width` breakpoints
355
+ and costs 7.5K of fallbacks on the template's own sheet, for engines that still
356
+ cannot render its `@property` and `color-mix()` values.
357
+
284
358
  ## Verify: the gate
285
359
 
286
360
  `bun run verify` maps to `site-runtime verify` and is the gate. It is silent on
287
361
  success and prints the failing step's full output on failure. If it fails, the
288
362
  change is broken. In order:
289
363
 
290
- 1. **font integrity**: every `public/fonts/*.woff2` is a real WOFF2 whose header
364
+ 1. **dependency tiers**: this repo declares no toolchain package, every toolchain
365
+ package RESOLVES the version this runtime declares (an `overrides` entry
366
+ declares nothing, so the installed manifests are what get read), and exactly
367
+ one astro resolves.
368
+ 2. **font integrity**: every `public/fonts/*.woff2` is a real WOFF2 whose header
291
369
  size matches the file, and every local `@font-face` URL resolves. Corrupt
292
370
  fonts fail silently at runtime, with a green build.
293
- 2. **island imports**: no island-grade ui primitive is imported under
371
+ 3. **island imports**: no island-grade ui primitive is imported under
294
372
  `src/components/sections/`.
295
- 3. **eslint**, then **astro check**, then the repo's own unit tests, then
373
+ 4. **eslint**, then **astro check**, then the repo's own unit tests, then
296
374
  **astro build**.
297
- 4. **bespoke siblings**: every non-draft bespoke page satisfies the two
375
+ 5. **bespoke siblings**: every non-draft bespoke page satisfies the two
298
376
  hydration conventions, checked against the fresh `dist/`, plus a pin on the
299
377
  Astro runtime directive contract they rely on.
300
- 5. **editor bytes**: a production build contains zero visual-editor bytes.
378
+ 6. **prerendered pages**: every `.html` the build wrote is an HTML document. A
379
+ prerendered route renders inside workerd, and when that goes wrong the build
380
+ still exits 0 and the file holds whatever the failure stringified to. The usual
381
+ cause is a wrangler `compatibility_date` earlier than `2026-02-19`, which makes
382
+ every page render as the string `[object Object]`.
383
+ 7. **editor bytes**: a production build contains zero visual-editor bytes.
384
+ 8. **kit overrides**: advisory only, printed after everything passes.
301
385
 
302
386
  Other subcommands: `site-runtime scan-copy [files...]`,
303
387
  `site-runtime scan-island-imports [dir]`, `site-runtime scan-bespoke-siblings`.
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
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
6
  "scripts": {
7
7
  "clean": "git clean -xdf .cache .turbo node_modules fixtures",
8
8
  "format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,mdx}\" --ignore-path=\"../../.prettierignore\"",
9
9
  "format:fix": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,mdx}\" --ignore-path=\"../../.prettierignore\"",
10
+ "kit-table": "node scripts/generate-kit-table.mjs",
10
11
  "lint": "eslint . --max-warnings 0",
11
12
  "lint:fix": "eslint --fix .",
13
+ "preversion": "node scripts/generate-kit-table.mjs",
12
14
  "test": "vitest run && node scripts/check-fixture.mjs",
13
15
  "test:fixture": "node scripts/check-fixture.mjs",
14
16
  "test:packed": "node scripts/check-packed.mjs",
@@ -27,6 +29,7 @@
27
29
  "!**/*.test.ts",
28
30
  "!scripts/check-fixture.mjs",
29
31
  "!scripts/check-packed.mjs",
32
+ "!scripts/generate-kit-table.mjs",
30
33
  "!src/lib/__fixtures__"
31
34
  ],
32
35
  "exports": {
@@ -49,32 +52,62 @@
49
52
  "./integrations/*": "./src/integrations/*.mjs",
50
53
  "./package.json": "./package.json"
51
54
  },
55
+ "iterant": {
56
+ "toolchain": [
57
+ "astro",
58
+ "react",
59
+ "react-dom",
60
+ "tailwindcss",
61
+ "@tailwindcss/vite",
62
+ "@astrojs/cloudflare",
63
+ "@astrojs/react",
64
+ "@astrojs/sitemap",
65
+ "@astrojs/check"
66
+ ],
67
+ "kit": [
68
+ "lucide-react",
69
+ "motion",
70
+ "radix-ui",
71
+ "@radix-ui/react-slot",
72
+ "embla-carousel-react",
73
+ "clsx",
74
+ "tailwind-merge",
75
+ "class-variance-authority",
76
+ "tw-animate-css",
77
+ "github-slugger"
78
+ ]
79
+ },
52
80
  "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"
81
+ "@astrojs/check": "0.9.10",
82
+ "@astrojs/cloudflare": "14.2.0",
83
+ "@astrojs/react": "6.0.2",
84
+ "@astrojs/sitemap": "3.7.3",
85
+ "@radix-ui/react-slot": "1.3.3",
86
+ "@tailwindcss/vite": "4.3.3",
87
+ "astro": "7.2.0",
88
+ "class-variance-authority": "0.7.1",
89
+ "clsx": "2.1.1",
90
+ "embla-carousel-react": "8.6.0",
91
+ "github-slugger": "2.0.0",
92
+ "lucide-react": "1.31.0",
93
+ "motion": "13.0.0",
94
+ "radix-ui": "1.6.7",
95
+ "react": "19.2.8",
96
+ "react-dom": "19.2.8",
97
+ "schema-dts": "2.0.0",
98
+ "tailwind-merge": "3.6.0",
99
+ "tailwindcss": "4.3.3",
100
+ "tw-animate-css": "1.4.0"
57
101
  },
58
102
  "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"
103
+ "typescript": "^5.6.0 || ^6.0.0"
65
104
  },
66
105
  "devDependencies": {
67
- "@astrojs/check": "^0.9.8",
68
- "@tailwindcss/vite": "4.2.2",
69
106
  "@types/node": "24.3.1",
70
107
  "@types/react": "19.2.14",
71
108
  "@types/react-dom": "19.2.3",
72
109
  "@workspace/eslint-config": "workspace:*",
73
110
  "@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
111
  "typescript": "5.9.2",
79
112
  "vitest": "^4.0.13",
80
113
  "wrangler": "^4.107.0"
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * The dependency-tier gates. Since 3.1.0 this package OWNS the toolchain and
5
+ * ships a curated kit, so who declares what is a checkable fact:
6
+ *
7
+ * Tier 1, the toolchain (astro, react, react-dom, tailwind + its vite plugin,
8
+ * the adapters, astro check). Package-versioned, exact, NOT overridable. A
9
+ * repo that declares one of these installs a SECOND copy: `astro dev` then
10
+ * resolves one version while the runtime's components were built and gated
11
+ * against another, and two reacts in one page break hooks with no build
12
+ * signal. So a declaration FAILS verify (`checkToolchainDeclarations`), and
13
+ * exactly one astro has to resolve from the repo root (`checkAstroCopies`).
14
+ *
15
+ * Tier 2, the curated kit (lucide-react, motion, radix, embla, clsx and
16
+ * friends). The package's version is the platform DEFAULT and a repo
17
+ * overrides it by simply declaring its own: node resolution puts the
18
+ * top-level copy first, so no mechanism is needed and none is added. The
19
+ * override is reported, never blocked (`reportKitOverrides`), which is the
20
+ * guidance-over-gates posture applied to dependencies.
21
+ *
22
+ * Both tiers are read from this package's own package.json: the names from
23
+ * `iterant.toolchain` / `iterant.kit`, the versions from `dependencies`. That
24
+ * is the single place a library is added or moved between tiers, and it is the
25
+ * same source docs/runtime-contract.md's available-libraries table is generated
26
+ * from, so the table cannot drift from what installs.
27
+ *
28
+ * Imported by scripts/verify.mjs. Runs standalone for a quick read:
29
+ * `node scripts/dependency-tiers.mjs [repoDir]`.
30
+ */
31
+
32
+ import { readdir, readFile, realpath } from "node:fs/promises";
33
+ import { join, resolve } from "node:path";
34
+ import { fileURLToPath, pathToFileURL } from "node:url";
35
+
36
+ const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
37
+
38
+ /** @typedef {{ name: string; version: string }} Library */
39
+
40
+ /** @param {string} path */
41
+ async function readJson(path) {
42
+ try {
43
+ return JSON.parse(await readFile(path, "utf8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * This package's own manifest: the source of truth for both tiers.
51
+ *
52
+ * @returns {Promise<{ name: string; version: string; toolchain: Library[]; kit: Library[] }>}
53
+ */
54
+ export async function readRuntimeTiers() {
55
+ const manifest = await readJson(join(PACKAGE_ROOT, "package.json"));
56
+ if (!manifest) {
57
+ throw new Error(
58
+ "dependency-tiers: @iterant/site-runtime's own package.json is unreadable",
59
+ );
60
+ }
61
+ const dependencies = manifest.dependencies ?? {};
62
+ /** @param {unknown} names */
63
+ const libraries = (names) =>
64
+ (Array.isArray(names) ? names : []).map((name) => ({
65
+ name: String(name),
66
+ version: String(dependencies[String(name)] ?? ""),
67
+ }));
68
+ return {
69
+ name: manifest.name,
70
+ version: manifest.version,
71
+ toolchain: libraries(manifest.iterant?.toolchain),
72
+ kit: libraries(manifest.iterant?.kit),
73
+ };
74
+ }
75
+
76
+ /** Every dependency map a repo can declare a package in. */
77
+ const DECLARED_IN = [
78
+ "dependencies",
79
+ "devDependencies",
80
+ "optionalDependencies",
81
+ "peerDependencies",
82
+ ];
83
+
84
+ /**
85
+ * @param {Record<string, unknown> | null} manifest
86
+ * @param {string} name
87
+ * @returns {{ field: string; range: string } | null}
88
+ */
89
+ function declaration(manifest, name) {
90
+ if (!manifest) return null;
91
+ for (const field of DECLARED_IN) {
92
+ const map = manifest[field];
93
+ if (!map || typeof map !== "object") continue;
94
+ const range = /** @type {Record<string, unknown>} */ (map)[name];
95
+ if (typeof range === "string") return { field, range };
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Tier-1 declaration gate. Returns one problem per declared toolchain package,
102
+ * each naming the eject path, plus a closing line with the whole list.
103
+ *
104
+ * @param {string} repoDir
105
+ * @returns {Promise<string[]>}
106
+ */
107
+ export async function checkToolchainDeclarations(repoDir) {
108
+ const { toolchain, version } = await readRuntimeTiers();
109
+ const manifest = await readJson(join(repoDir, "package.json"));
110
+ const problems = [];
111
+ for (const { name, version: owned } of toolchain) {
112
+ const declared = declaration(manifest, name);
113
+ if (!declared) continue;
114
+ problems.push(
115
+ `package.json declares ${name}@${declared.range} in ${declared.field}, but @iterant/site-runtime ${version} owns it at ${owned}. ` +
116
+ `Remove the entry and reinstall: the runtime installs the toolchain, and \`astro dev\` / \`astro build\` resolve from that one hoisted copy.`,
117
+ );
118
+ }
119
+ if (problems.length > 0) {
120
+ problems.push(
121
+ `The toolchain is ${toolchain.map(({ name }) => name).join(", ")}. ` +
122
+ `A repo that genuinely needs different versions ejects the runtime: drop the @iterant/site-runtime dependency and paste the platform files back into src/, ` +
123
+ `the same marker convention a replaced shell.astro uses. Say so when you do, because an ejected repo stops receiving platform fixes.`,
124
+ );
125
+ }
126
+ return problems;
127
+ }
128
+
129
+ /**
130
+ * Tier-1 version gate: what is INSTALLED has to be what this runtime declares.
131
+ *
132
+ * The declaration gate reads the four dependency maps, which is where a repo
133
+ * declares a package, and that is exactly the hole: `overrides` (npm, bun) and
134
+ * `resolutions` (yarn) pin a version without appearing in any of them, so a repo
135
+ * could hold astro at 6.4.8 and pass every other check. The copy count does not
136
+ * see it either, since one copy of the wrong version is still one copy. Reading
137
+ * the installed manifests answers the question the other two only approximate,
138
+ * and it covers every tier-1 package rather than astro alone: react resolving a
139
+ * major behind is the same class of break and used to go unmentioned.
140
+ *
141
+ * A missing package is NOT reported here. That is either a repo the runtime was
142
+ * never installed in, which the astro-copy check states more usefully, or an
143
+ * optional-in-practice tool, and a gate that fires twice for one cause teaches
144
+ * people to skim it.
145
+ *
146
+ * @param {string} repoDir
147
+ * @returns {Promise<string[]>}
148
+ */
149
+ export async function checkToolchainVersions(repoDir) {
150
+ const { toolchain, version } = await readRuntimeTiers();
151
+ /** @type {string[]} */
152
+ const problems = [];
153
+ for (const { name, version: owned } of toolchain) {
154
+ const installed = await readJson(
155
+ join(repoDir, "node_modules", name, "package.json"),
156
+ );
157
+ if (!installed?.version) continue;
158
+ if (String(installed.version) === owned) continue;
159
+ problems.push(
160
+ `${name} resolves ${installed.version}, but @iterant/site-runtime ${version} is built and gated against ${owned}. ` +
161
+ `Nothing in package.json's dependencies asks for that version, so it comes from an \`overrides\` / \`resolutions\` entry or a stale lockfile: ` +
162
+ `remove the pin and reinstall. A toolchain the runtime did not choose is a runtime nobody tested.`,
163
+ );
164
+ }
165
+ return problems;
166
+ }
167
+
168
+ /**
169
+ * Every astro copy reachable from the repo root: the hoisted one plus any
170
+ * nested under a top-level dependency. Deduplicated by real path, because the
171
+ * same install shows up as both a symlink and its target.
172
+ *
173
+ * @param {string} repoDir
174
+ * @returns {Promise<Array<{ path: string; version: string }>>}
175
+ */
176
+ async function resolveAstroCopies(repoDir) {
177
+ const modules = join(repoDir, "node_modules");
178
+ /** @type {string[]} */
179
+ const candidates = [join(modules, "astro")];
180
+ /** @type {import("node:fs").Dirent[]} */
181
+ let entries = [];
182
+ try {
183
+ entries = await readdir(modules, { withFileTypes: true });
184
+ } catch {
185
+ return [];
186
+ }
187
+ for (const entry of entries) {
188
+ if (entry.name.startsWith(".")) continue;
189
+ // A scope directory holds packages, not a package: descend one more level.
190
+ if (entry.name.startsWith("@")) {
191
+ /** @type {import("node:fs").Dirent[]} */
192
+ let scoped = [];
193
+ try {
194
+ scoped = await readdir(join(modules, entry.name), {
195
+ withFileTypes: true,
196
+ });
197
+ } catch {
198
+ continue;
199
+ }
200
+ for (const inner of scoped) {
201
+ candidates.push(
202
+ join(modules, entry.name, inner.name, "node_modules", "astro"),
203
+ );
204
+ }
205
+ continue;
206
+ }
207
+ candidates.push(join(modules, entry.name, "node_modules", "astro"));
208
+ }
209
+
210
+ /** @type {Map<string, { path: string; version: string }>} */
211
+ const found = new Map();
212
+ for (const candidate of candidates) {
213
+ const manifest = await readJson(join(candidate, "package.json"));
214
+ if (!manifest?.version) continue;
215
+ const key = await realpath(candidate).catch(() => candidate);
216
+ if (found.has(key)) continue;
217
+ found.set(key, { path: candidate, version: String(manifest.version) });
218
+ }
219
+ return [...found.values()];
220
+ }
221
+
222
+ /**
223
+ * Single-copy invariant. Two astros mean the build and the gates can disagree
224
+ * about which one ran; none means the toolchain never installed.
225
+ *
226
+ * @param {string} repoDir
227
+ * @returns {Promise<string[]>}
228
+ */
229
+ export async function checkAstroCopies(repoDir) {
230
+ const copies = await resolveAstroCopies(repoDir);
231
+ if (copies.length === 1) return [];
232
+ if (copies.length === 0) {
233
+ return [
234
+ "no astro resolves from this repo. The toolchain installs with @iterant/site-runtime, so this is an incomplete install: reinstall from the repo root.",
235
+ ];
236
+ }
237
+ return [
238
+ `${copies.length} copies of astro resolve from this repo, so the build and these gates can run different versions:\n` +
239
+ copies.map(({ path, version }) => ` ${version} ${path}`).join("\n") +
240
+ "\nRemove the duplicate declaration and reinstall.",
241
+ ];
242
+ }
243
+
244
+ /**
245
+ * Tier-2 override report. Advisory: it never fails, it only names what the repo
246
+ * has taken over from the platform default and at which version it resolved.
247
+ *
248
+ * @param {string} repoDir
249
+ * @returns {Promise<string[]>}
250
+ */
251
+ export async function reportKitOverrides(repoDir) {
252
+ const { kit } = await readRuntimeTiers();
253
+ const manifest = await readJson(join(repoDir, "package.json"));
254
+ const lines = [];
255
+ for (const { name, version: platformDefault } of kit) {
256
+ const declared = declaration(manifest, name);
257
+ if (!declared) continue;
258
+ const installed = await readJson(
259
+ join(repoDir, "node_modules", name, "package.json"),
260
+ );
261
+ const resolved = installed?.version
262
+ ? String(installed.version)
263
+ : "not installed";
264
+ lines.push(
265
+ `${name}: this repo declares ${declared.range} and resolved ${resolved}; the platform default is ${platformDefault}.`,
266
+ );
267
+ }
268
+ return lines;
269
+ }
270
+
271
+ /** The advisory's framing, printed only when there is an override to report. */
272
+ export const KIT_OVERRIDE_PREAMBLE =
273
+ "kit override (advisory, verify passed): the platform ships these libraries and this repo pins its own versions, so platform bumps no longer move them.";
274
+
275
+ // Standalone CLI: `node scripts/dependency-tiers.mjs [repoDir]`. Prints the
276
+ // tier-1 problems to stderr and exits 1; prints the tier-2 advisory and stays
277
+ // at 0.
278
+ if (
279
+ process.argv[1] &&
280
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
281
+ ) {
282
+ const repoDir = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
283
+ const problems = [
284
+ ...(await checkToolchainDeclarations(repoDir)),
285
+ ...(await checkToolchainVersions(repoDir)),
286
+ ...(await checkAstroCopies(repoDir)),
287
+ ];
288
+ const overrides = await reportKitOverrides(repoDir);
289
+ if (overrides.length > 0) {
290
+ process.stdout.write(`${KIT_OVERRIDE_PREAMBLE}\n${overrides.join("\n")}\n`);
291
+ }
292
+ if (problems.length > 0) {
293
+ process.stderr.write(`toolchain check failed:\n${problems.join("\n")}\n`);
294
+ process.exit(1);
295
+ }
296
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * Prerendered-page gate. A prerendered route is rendered inside workerd by the
5
+ * Cloudflare adapter and written to disk, and when that render goes wrong the
6
+ * build stays GREEN and the file gets whatever the failure stringified to.
7
+ *
8
+ * The live case (3.1.0, astro 7): a repo whose wrangler `compatibility_date`
9
+ * predates 2026-02-19 wrote the literal string "[object Object]" as its entire
10
+ * home page, with no warning in the build log, and the deployed worker served
11
+ * the same body for every SSR route. Nothing else looks at what the build
12
+ * actually produced, so a page that is not a page shipped.
13
+ *
14
+ * Every .html under dist/client has to start like a document. Run by
15
+ * `site-runtime verify` in a brand repo and by this package's own
16
+ * scripts/check-fixture.mjs against the fixture it just built, so the failure
17
+ * mode is covered by `pnpm test` and not only by a consumer's gate.
18
+ */
19
+
20
+ import { readdir, readFile } from "node:fs/promises";
21
+ import { join, relative, resolve } from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+
24
+ /** The floor a Worker's compatibility_date must clear for the render to work. */
25
+ export const WORKERD_COMPATIBILITY_FLOOR = "2026-02-19";
26
+
27
+ const DOCUMENT_START = /^<(?:!doctype html|html[\s>])/i;
28
+
29
+ /**
30
+ * @param {string} repoDir project root; dist/client is read beneath it
31
+ * @returns {Promise<string[]>} one problem per file that is not a document
32
+ */
33
+ export async function checkPrerenderedPages(repoDir) {
34
+ /** @type {string[]} */
35
+ const problems = [];
36
+ const clientDir = join(repoDir, "dist", "client");
37
+
38
+ /** @param {string} dir */
39
+ async function walk(dir) {
40
+ /** @type {import("node:fs").Dirent[]} */
41
+ let entries = [];
42
+ try {
43
+ entries = await readdir(dir, { withFileTypes: true });
44
+ } catch {
45
+ return;
46
+ }
47
+ for (const entry of entries) {
48
+ const path = join(dir, entry.name);
49
+ if (entry.isDirectory()) {
50
+ await walk(path);
51
+ continue;
52
+ }
53
+ if (!entry.name.endsWith(".html")) continue;
54
+ const head = (await readFile(path, "utf8")).slice(0, 200).trimStart();
55
+ if (DOCUMENT_START.test(head)) continue;
56
+ problems.push(
57
+ `${relative(repoDir, path)} is not an HTML document. It begins: ${JSON.stringify(head.slice(0, 60))}. ` +
58
+ `A prerendered route rendered to something other than markup, which the build reports as success. ` +
59
+ `First thing to check is wrangler compatibility_date: the runtime's prerender and SSR need ${WORKERD_COMPATIBILITY_FLOOR} or later.`,
60
+ );
61
+ }
62
+ }
63
+
64
+ await walk(clientDir);
65
+ return problems;
66
+ }
67
+
68
+ // Standalone CLI: `node scripts/prerendered-pages.mjs [repoDir]`.
69
+ if (
70
+ process.argv[1] &&
71
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
72
+ ) {
73
+ const problems = await checkPrerenderedPages(
74
+ process.argv[2] ? resolve(process.argv[2]) : process.cwd(),
75
+ );
76
+ if (problems.length > 0) {
77
+ process.stderr.write(
78
+ `prerendered-page check failed:\n${problems.join("\n")}\n`,
79
+ );
80
+ process.exit(1);
81
+ }
82
+ }
@@ -24,6 +24,11 @@
24
24
  * exits with that step's exit code. Saves tokens in agent tool
25
25
  * results and puts the useful output front-and-center.
26
26
  *
27
+ * ONE exception, added with the curated kit in 3.1.0: a repo that pins
28
+ * its own version of a kit library gets an advisory line about it after
29
+ * everything passes. It cannot fail the run, and a repo that declares
30
+ * none of them (every fresh repo) still prints nothing.
31
+ *
27
32
  * Run from the repo root via `bun run verify` / `npm run verify` (which map to
28
33
  * `site-runtime verify`). Every path below resolves against process.cwd(), the
29
34
  * repo being verified — the gates themselves live in node_modules.
@@ -172,6 +177,28 @@ async function checkFonts() {
172
177
  return problems;
173
178
  }
174
179
 
180
+ // Dependency-tier gate, FIRST because it decides which toolchain the rest of
181
+ // this run measures. The runtime owns the toolchain (3.1.0), so a repo that
182
+ // declares astro or react installs a second copy and every step below silently
183
+ // reports on the wrong one. Three questions, because each one has a hole the
184
+ // others cover: who DECLARES a toolchain package, what version actually
185
+ // RESOLVED (an overrides entry declares nothing), and how many copies of astro
186
+ // there are. The checks and their messages live in dependency-tiers.mjs so its
187
+ // tests can exercise them on fixtures.
188
+ const { checkAstroCopies, checkToolchainDeclarations, checkToolchainVersions } =
189
+ await import("./dependency-tiers.mjs");
190
+ const toolchainProblems = [
191
+ ...(await checkToolchainDeclarations(process.cwd())),
192
+ ...(await checkToolchainVersions(process.cwd())),
193
+ ...(await checkAstroCopies(process.cwd())),
194
+ ];
195
+ if (toolchainProblems.length > 0) {
196
+ process.stderr.write(
197
+ `toolchain check failed:\n${toolchainProblems.join("\n")}\n`,
198
+ );
199
+ exit(1);
200
+ }
201
+
175
202
  const fontProblems = await checkFonts();
176
203
  if (fontProblems.length > 0) {
177
204
  process.stderr.write(
@@ -222,6 +249,19 @@ for (const [cmd, args] of steps) {
222
249
  }
223
250
  }
224
251
 
252
+ // Prerendered-page gate, against the dist/ the build just wrote. The check and
253
+ // its message live in prerendered-pages.mjs so this package's own fixture check
254
+ // runs the same assertion on the same kind of output: a broken prerender is
255
+ // invisible to the build's exit code, so the only witness is the file.
256
+ const { checkPrerenderedPages } = await import("./prerendered-pages.mjs");
257
+ const prerenderProblems = await checkPrerenderedPages(process.cwd());
258
+ if (prerenderProblems.length > 0) {
259
+ process.stderr.write(
260
+ `prerendered-page check failed:\n${prerenderProblems.join("\n")}\n`,
261
+ );
262
+ exit(1);
263
+ }
264
+
225
265
  /**
226
266
  * Editor-bytes gate. The visual editor is dev-only — injected by
227
267
  * integrations/visual-editor-dev.mjs under `astro dev`, with the vendored
@@ -279,5 +319,18 @@ if (editorProblems.length > 0) {
279
319
  exit(1);
280
320
  }
281
321
 
322
+ // The one thing a green verify prints: which curated-kit libraries this repo has
323
+ // taken over from the platform default. It runs LAST so "verify passed, and here
324
+ // is a note" cannot be misread as the cause of a failure, and it says nothing at
325
+ // all for a repo that declares none of them, which is every fresh repo.
326
+ const { KIT_OVERRIDE_PREAMBLE, reportKitOverrides } =
327
+ await import("./dependency-tiers.mjs");
328
+ const kitOverrides = await reportKitOverrides(process.cwd());
329
+ if (kitOverrides.length > 0) {
330
+ process.stderr.write(
331
+ `${KIT_OVERRIDE_PREAMBLE}\n${kitOverrides.join("\n")}\n`,
332
+ );
333
+ }
334
+
282
335
  // Success: exit silently. Nothing to print.
283
336
  exit(0);
@@ -11,6 +11,32 @@ import newFileReload from "../integrations/new-file-reload.mjs";
11
11
  import previewErrorShell from "../integrations/preview-error-shell.mjs";
12
12
  import { sitemapWithCustomPages } from "../lib/sitemap";
13
13
 
14
+ /**
15
+ * The browser floor every brand site's CSS is compiled for, stated rather than
16
+ * inherited. Esbuild-style target strings, because that is what Vite parses for
17
+ * both CSS minifiers.
18
+ *
19
+ * Vite 8 minifies CSS with Lightning CSS instead of esbuild, and its minify step
20
+ * takes targets from `build.cssTarget` alone: with that unset it passes `{}` and
21
+ * leaves modern syntax exactly as Tailwind emits it. Astro 6's output carried
22
+ * `@media(min-width:40rem)` and astro 7's carried `@media (width>=40rem)`, which
23
+ * older engines do not parse, so every breakpoint in the sheet would silently
24
+ * stop applying on them.
25
+ *
26
+ * The floor is Tailwind v4's own, which is also the floor these stylesheets
27
+ * already had: the same build emits 54 `@property` declarations and 52
28
+ * `color-mix()` calls with no fallbacks, in both the astro 6 and astro 7 output.
29
+ * Range media queries are supported at exactly that line (Safari 16.4,
30
+ * Chrome 104, Firefox 63), so declaring the floor keeps the modern syntax
31
+ * honestly rather than lowering one feature while the rest of the sheet needs the
32
+ * same engines. Lower these numbers and Lightning CSS lowers the syntax to match.
33
+ *
34
+ * NOT `css.lightningcss.targets`: the minify path overwrites that key with
35
+ * whatever `build.cssTarget` converts to, so setting it there looks right, passes
36
+ * every gate, and does nothing.
37
+ */
38
+ const CSS_TARGET = ["safari16.4", "chrome111", "firefox128", "edge111"];
39
+
14
40
  // The Astro configuration every brand site runs on, as one spreadable fragment:
15
41
  //
16
42
  // // astro.config.mjs
@@ -70,9 +96,12 @@ export function iterantStarter({
70
96
  pagesDir,
71
97
  overrides,
72
98
  }: IterantStarterOptions = {}) {
73
- // The platform generates the wrangler config at deploy time; it isn't checked
74
- // in. Only pass configPath to the Cloudflare adapter when one of the supported
75
- // wrangler config files actually exists so local `astro dev` works without one.
99
+ // A repo's own wrangler config, when it has one. The template checks in a
100
+ // wrangler.jsonc and its compatibility_date decides how the adapter's workerd
101
+ // prerender behaves, so passing configPath is not a detail; the lookup stays
102
+ // conditional because a repo without any of these files still has to `astro
103
+ // dev`. The Worker the platform UPLOADS is configured separately, by the
104
+ // deploy path, and nothing writes a wrangler file into the repo.
76
105
  const wranglerConfig = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]
77
106
  .map((name) => join(process.cwd(), name))
78
107
  .find((path) => existsSync(path));
@@ -120,6 +149,12 @@ export function iterantStarter({
120
149
  },
121
150
  }),
122
151
  },
152
+ // The CSS floor (see CSS_TARGET). Vite's CSS minifier reads this and nothing
153
+ // else, and it is what stops the build emitting syntax the declared browsers
154
+ // cannot parse.
155
+ build: {
156
+ cssTarget: CSS_TARGET,
157
+ },
123
158
  optimizeDeps: {
124
159
  // Pre-bundle every client-side dep up front: discovering one
125
160
  // mid-session re-optimizes with a new ?v hash, and a stale
@@ -166,6 +201,14 @@ export function iterantStarter({
166
201
  imageService: "compile",
167
202
  ...(wranglerConfig && { configPath: wranglerConfig }),
168
203
  }),
204
+ // HTML-aware compression, which was Astro's default until 7.0 changed it to
205
+ // "jsx". Set explicitly rather than inherited: under "jsx" a whitespace-only
206
+ // text node between two elements is dropped by JSX rules, so `<b>a</b>
207
+ // <i>b</i>` in a brand's own section renders as "ab". That is a rendered
208
+ // output change in brand-owned markup, which the semver contract does not
209
+ // allow a package MINOR to make; the runtime therefore keeps the old
210
+ // behavior and a later MAJOR can adopt "jsx" deliberately.
211
+ compressHTML: true,
169
212
  integrations,
170
213
  vite,
171
214
  // Port 4321 (Astro's default), NOT 3000: inside a Cloudflare Sandbox port
@@ -30,7 +30,11 @@ export function sitemapWithCustomPages(
30
30
  name: "capture-site-for-sitemap",
31
31
  hooks: {
32
32
  "astro:config:setup": ({ config, logger }) => {
33
- const { paths, pagesDir: searched, entryCount } = getSitemapPaths({
33
+ const {
34
+ paths,
35
+ pagesDir: searched,
36
+ entryCount,
37
+ } = getSitemapPaths({
34
38
  pagesDir,
35
39
  root: root ?? fileURLToPath(config.root),
36
40
  });