@iterant/site-runtime 3.0.2 → 3.1.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/docs/runtime-contract.md +90 -6
- package/package.json +50 -17
- package/scripts/dependency-tiers.mjs +296 -0
- package/scripts/prerendered-pages.mjs +82 -0
- package/scripts/scan-fetch-entrypoint.mjs +73 -0
- package/scripts/verify.mjs +66 -0
- package/src/config/preset.ts +52 -3
- package/src/content/collections.ts +5 -1
- package/src/content/resilience.ts +106 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +5 -1
package/docs/runtime-contract.md
CHANGED
|
@@ -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.2._
|
|
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
|
|
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. **
|
|
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
|
-
|
|
371
|
+
3. **island imports**: no island-grade ui primitive is imported under
|
|
294
372
|
`src/components/sections/`.
|
|
295
|
-
|
|
373
|
+
4. **eslint**, then **astro check**, then the repo's own unit tests, then
|
|
296
374
|
**astro build**.
|
|
297
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.1.2",
|
|
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/
|
|
54
|
-
"@astrojs/
|
|
55
|
-
"@astrojs/
|
|
56
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Reserved-filename gate: `src/fetch.*`.
|
|
4
|
+
*
|
|
5
|
+
* Astro 7 resolves `srcDir + "fetch"` as the request-pipeline entrypoint
|
|
6
|
+
* (advanced routing): when the module resolves, its default export replaces
|
|
7
|
+
* the default fetch handler for EVERY request, in dev and in production
|
|
8
|
+
* builds alike, and a failure to resolve falls back silently. Resolution
|
|
9
|
+
* goes through Vite, so every resolvable extension counts and a `src/fetch/`
|
|
10
|
+
* directory resolves through its index file. A brand repo must never carry
|
|
11
|
+
* one: an innocently named helper would silently take over the whole request
|
|
12
|
+
* pipeline, and a broken one takes every route down with it. The platform
|
|
13
|
+
* reserves the name for a future runtime-owned shim; until that ships, the
|
|
14
|
+
* file's absence is the contract.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readdir } from "node:fs/promises";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
// Vite's default resolve.extensions, which Astro does not override.
|
|
22
|
+
const RESOLVABLE = /^fetch\.(mjs|js|mts|ts|jsx|tsx|json)$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} rootDir
|
|
26
|
+
* @returns {Promise<string[]>}
|
|
27
|
+
*/
|
|
28
|
+
export async function checkFetchEntrypoint(rootDir) {
|
|
29
|
+
/** @type {import("node:fs").Dirent[]} */
|
|
30
|
+
let entries = [];
|
|
31
|
+
try {
|
|
32
|
+
entries = await readdir(join(rootDir, "src"), { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
// No src/ at all is astro check's problem, not this gate's.
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const problems = [];
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.isDirectory() && entry.name === "fetch") {
|
|
40
|
+
problems.push(
|
|
41
|
+
"src/fetch/ is a reserved name: Astro resolves it (through its index file) " +
|
|
42
|
+
"as the request-pipeline entrypoint for every route, dev and production. " +
|
|
43
|
+
"Rename the directory.",
|
|
44
|
+
);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (entry.isFile() && RESOLVABLE.test(entry.name)) {
|
|
48
|
+
problems.push(
|
|
49
|
+
`src/${entry.name} is a reserved filename: Astro loads it as the ` +
|
|
50
|
+
"request-pipeline entrypoint for every route, dev and production, " +
|
|
51
|
+
"replacing the platform's default handler. Rename the file.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return problems;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Standalone CLI: `node scripts/scan-fetch-entrypoint.mjs [rootDir]`.
|
|
59
|
+
// Prints problems to stderr and exits 1 when any are found.
|
|
60
|
+
if (
|
|
61
|
+
process.argv[1] &&
|
|
62
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
63
|
+
) {
|
|
64
|
+
const problems = await checkFetchEntrypoint(
|
|
65
|
+
process.argv[2] ? resolve(process.argv[2]) : process.cwd(),
|
|
66
|
+
);
|
|
67
|
+
if (problems.length > 0) {
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
`fetch-entrypoint check failed:\n${problems.join("\n")}\n`,
|
|
70
|
+
);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/scripts/verify.mjs
CHANGED
|
@@ -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(
|
|
@@ -180,6 +207,19 @@ if (fontProblems.length > 0) {
|
|
|
180
207
|
exit(1);
|
|
181
208
|
}
|
|
182
209
|
|
|
210
|
+
// Fetch-entrypoint gate — Astro loads src/fetch.* as the request-pipeline
|
|
211
|
+
// entrypoint for EVERY route, dev and production, so an innocently named
|
|
212
|
+
// helper silently takes over the whole pipeline. The check lives in
|
|
213
|
+
// scan-fetch-entrypoint.mjs so its tests can exercise it on fixtures.
|
|
214
|
+
const { checkFetchEntrypoint } = await import("./scan-fetch-entrypoint.mjs");
|
|
215
|
+
const fetchProblems = await checkFetchEntrypoint(process.cwd());
|
|
216
|
+
if (fetchProblems.length > 0) {
|
|
217
|
+
process.stderr.write(
|
|
218
|
+
`fetch-entrypoint check failed:\n${fetchProblems.join("\n")}\n`,
|
|
219
|
+
);
|
|
220
|
+
exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
183
223
|
// Island-import gate — registered sections never hydrate, so an island-grade
|
|
184
224
|
// ui primitive imported there ships a dead widget with zero build signal.
|
|
185
225
|
// The check (direct + barrel + any-depth relative imports; type-only imports
|
|
@@ -222,6 +262,19 @@ for (const [cmd, args] of steps) {
|
|
|
222
262
|
}
|
|
223
263
|
}
|
|
224
264
|
|
|
265
|
+
// Prerendered-page gate, against the dist/ the build just wrote. The check and
|
|
266
|
+
// its message live in prerendered-pages.mjs so this package's own fixture check
|
|
267
|
+
// runs the same assertion on the same kind of output: a broken prerender is
|
|
268
|
+
// invisible to the build's exit code, so the only witness is the file.
|
|
269
|
+
const { checkPrerenderedPages } = await import("./prerendered-pages.mjs");
|
|
270
|
+
const prerenderProblems = await checkPrerenderedPages(process.cwd());
|
|
271
|
+
if (prerenderProblems.length > 0) {
|
|
272
|
+
process.stderr.write(
|
|
273
|
+
`prerendered-page check failed:\n${prerenderProblems.join("\n")}\n`,
|
|
274
|
+
);
|
|
275
|
+
exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
225
278
|
/**
|
|
226
279
|
* Editor-bytes gate. The visual editor is dev-only — injected by
|
|
227
280
|
* integrations/visual-editor-dev.mjs under `astro dev`, with the vendored
|
|
@@ -279,5 +332,18 @@ if (editorProblems.length > 0) {
|
|
|
279
332
|
exit(1);
|
|
280
333
|
}
|
|
281
334
|
|
|
335
|
+
// The one thing a green verify prints: which curated-kit libraries this repo has
|
|
336
|
+
// taken over from the platform default. It runs LAST so "verify passed, and here
|
|
337
|
+
// is a note" cannot be misread as the cause of a failure, and it says nothing at
|
|
338
|
+
// all for a repo that declares none of them, which is every fresh repo.
|
|
339
|
+
const { KIT_OVERRIDE_PREAMBLE, reportKitOverrides } =
|
|
340
|
+
await import("./dependency-tiers.mjs");
|
|
341
|
+
const kitOverrides = await reportKitOverrides(process.cwd());
|
|
342
|
+
if (kitOverrides.length > 0) {
|
|
343
|
+
process.stderr.write(
|
|
344
|
+
`${KIT_OVERRIDE_PREAMBLE}\n${kitOverrides.join("\n")}\n`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
282
348
|
// Success: exit silently. Nothing to print.
|
|
283
349
|
exit(0);
|
package/src/config/preset.ts
CHANGED
|
@@ -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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
|
@@ -150,6 +185,12 @@ export function iterantStarter({
|
|
|
150
185
|
// Tunnel/preview hostnames are random per boot, so hostname
|
|
151
186
|
// allow-listing is impossible; access control lives outside the dev server.
|
|
152
187
|
allowedHosts,
|
|
188
|
+
// Vite 8 turns this on by itself when it sniffs an AI agent in the
|
|
189
|
+
// environment, and a forwarded browser console (any viewer with the
|
|
190
|
+
// preview open) would interleave into the process logs the platform
|
|
191
|
+
// parses for readiness and boot briefs. Pinned so a heuristic never
|
|
192
|
+
// decides the log shape; enabling it is a deliberate platform decision.
|
|
193
|
+
forwardConsole: false,
|
|
153
194
|
},
|
|
154
195
|
};
|
|
155
196
|
|
|
@@ -166,6 +207,14 @@ export function iterantStarter({
|
|
|
166
207
|
imageService: "compile",
|
|
167
208
|
...(wranglerConfig && { configPath: wranglerConfig }),
|
|
168
209
|
}),
|
|
210
|
+
// HTML-aware compression, which was Astro's default until 7.0 changed it to
|
|
211
|
+
// "jsx". Set explicitly rather than inherited: under "jsx" a whitespace-only
|
|
212
|
+
// text node between two elements is dropped by JSX rules, so `<b>a</b>
|
|
213
|
+
// <i>b</i>` in a brand's own section renders as "ab". That is a rendered
|
|
214
|
+
// output change in brand-owned markup, which the semver contract does not
|
|
215
|
+
// allow a package MINOR to make; the runtime therefore keeps the old
|
|
216
|
+
// behavior and a later MAJOR can adopt "jsx" deliberately.
|
|
217
|
+
compressHTML: true,
|
|
169
218
|
integrations,
|
|
170
219
|
vite,
|
|
171
220
|
// Port 4321 (Astro's default), NOT 3000: inside a Cloudflare Sandbox port
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
loaderBase,
|
|
8
8
|
} from "../lib/content-paths";
|
|
9
9
|
import { entryIdFromFile } from "../lib/locales";
|
|
10
|
+
import { withDevQuarantine } from "./resilience";
|
|
10
11
|
import { createContentSchemas, type ContentSchemaOptions } from "./schema";
|
|
11
12
|
|
|
12
13
|
// The `pages` + `chrome` collections a brand site runs on. Astro requires the
|
|
@@ -53,7 +54,10 @@ export function createCollections({
|
|
|
53
54
|
base: loaderBase(pagesDir),
|
|
54
55
|
generateId: ({ entry }) => entryIdFromFile(entry),
|
|
55
56
|
}),
|
|
56
|
-
|
|
57
|
+
// Strict at build/verify; in dev an invalid entry is quarantined (logged
|
|
58
|
+
// in full, route degrades to an error stand-in) instead of taking the
|
|
59
|
+
// whole content sync — and with it every route of the preview — down.
|
|
60
|
+
schema: withDevQuarantine(pageEntrySchema),
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
const chrome = defineCollection({
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
|
|
3
|
+
// Dev-server resilience for the pages collection (3.1.1). Astro 7 treats one
|
|
4
|
+
// invalid entry as fatal to the whole content sync, so a single half-finished
|
|
5
|
+
// page took the ENTIRE preview down — every route, plus the save gate's boot.
|
|
6
|
+
// In dev we quarantine instead: the strict schema still decides validity, the
|
|
7
|
+
// violations are logged in full, and the broken entry is replaced by a
|
|
8
|
+
// minimal valid stand-in (chromed page, no sections, error title) so its
|
|
9
|
+
// route degrades while every other page keeps serving. Builds stay strict:
|
|
10
|
+
// verify and publish reject the entry exactly as before, so nothing invalid
|
|
11
|
+
// can ship.
|
|
12
|
+
|
|
13
|
+
const ROUTE_PATTERN = /^\/[a-z0-9\-/]*$/;
|
|
14
|
+
|
|
15
|
+
type AnyPageSchema = z.ZodTypeAny;
|
|
16
|
+
|
|
17
|
+
export function isStrictContentEnv(env: NodeJS.ProcessEnv = process.env) {
|
|
18
|
+
// astro build sets NODE_ENV=production; astro dev sets development.
|
|
19
|
+
// SITE_RUNTIME_STRICT_CONTENT=1 forces build behavior in dev (escape hatch
|
|
20
|
+
// for debugging the strict path itself).
|
|
21
|
+
return (
|
|
22
|
+
env.NODE_ENV === "production" || env.SITE_RUNTIME_STRICT_CONTENT === "1"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Render a ZodError as one line per violation with full paths. Zod 4 already
|
|
27
|
+
// reports the informative branch of a union failure at its deep path (the
|
|
28
|
+
// bare-string wrapper rule lands on e.g. components.1.props.columns.items.1
|
|
29
|
+
// .key directly); genuinely ambiguous unions still collapse to one issue
|
|
30
|
+
// carrying per-branch issue lists in `errors`, so those are walked.
|
|
31
|
+
type UnionBranches = { errors?: z.ZodIssue[][] };
|
|
32
|
+
|
|
33
|
+
function describeIssues(
|
|
34
|
+
issues: z.ZodIssue[],
|
|
35
|
+
base: PropertyKey[] = [],
|
|
36
|
+
): string[] {
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
for (const issue of issues) {
|
|
39
|
+
const path = [...base, ...issue.path];
|
|
40
|
+
const branches = (issue as UnionBranches).errors;
|
|
41
|
+
if (issue.code === "invalid_union" && Array.isArray(branches)) {
|
|
42
|
+
lines.push(...describeIssues(branches.flat(), path));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
lines.push(`${path.map(String).join(".")}: ${issue.message}`);
|
|
46
|
+
}
|
|
47
|
+
return [...new Set(lines)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wrap the strict page-entry schema for collection use: pass-through when the
|
|
52
|
+
* entry is valid, quarantine when it is not (dev only). In a strict env
|
|
53
|
+
* (build/verify) and for an entry whose `route` field is itself unusable (it
|
|
54
|
+
* cannot be placed on a route), the original violations surface unchanged.
|
|
55
|
+
*/
|
|
56
|
+
export function withDevQuarantine<S extends AnyPageSchema>(
|
|
57
|
+
strict: S,
|
|
58
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
59
|
+
): z.ZodType<z.output<S>> {
|
|
60
|
+
const strictMode = isStrictContentEnv(env);
|
|
61
|
+
return z.unknown().transform((raw, ctx) => {
|
|
62
|
+
const parsed = strict.safeParse(raw);
|
|
63
|
+
if (parsed.success) return parsed.data as z.output<S>;
|
|
64
|
+
|
|
65
|
+
// Replay the strict schema's own issues through this pipeline — the cast
|
|
66
|
+
// bridges zod 4's public ZodIssue to addIssue's raw-issue input, which
|
|
67
|
+
// are the same objects under different declared types.
|
|
68
|
+
const surface = () => {
|
|
69
|
+
for (const issue of parsed.error.issues) {
|
|
70
|
+
ctx.addIssue(issue as never);
|
|
71
|
+
}
|
|
72
|
+
return z.NEVER;
|
|
73
|
+
};
|
|
74
|
+
if (strictMode) return surface();
|
|
75
|
+
|
|
76
|
+
const issues = describeIssues(parsed.error.issues);
|
|
77
|
+
const route =
|
|
78
|
+
raw !== null &&
|
|
79
|
+
typeof raw === "object" &&
|
|
80
|
+
"route" in raw &&
|
|
81
|
+
typeof raw.route === "string" &&
|
|
82
|
+
ROUTE_PATTERN.test(raw.route)
|
|
83
|
+
? raw.route
|
|
84
|
+
: null;
|
|
85
|
+
if (route === null) return surface();
|
|
86
|
+
|
|
87
|
+
console.error(
|
|
88
|
+
[
|
|
89
|
+
`[site-runtime] QUARANTINED invalid page entry for route "${route}".`,
|
|
90
|
+
"The dev server keeps serving; this route renders an error stand-in",
|
|
91
|
+
"until the entry is fixed. Publish/verify still reject it. Violations:",
|
|
92
|
+
...issues.map((line) => ` ${line}`),
|
|
93
|
+
].join("\n"),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return strict.parse({
|
|
97
|
+
route,
|
|
98
|
+
title: "This page has a content error",
|
|
99
|
+
meta: {
|
|
100
|
+
title: "This page has a content error",
|
|
101
|
+
description: `Invalid entry quarantined in dev: ${issues.join("; ").slice(0, 400)}`,
|
|
102
|
+
},
|
|
103
|
+
components: [],
|
|
104
|
+
}) as z.output<S>;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -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 {
|
|
33
|
+
const {
|
|
34
|
+
paths,
|
|
35
|
+
pagesDir: searched,
|
|
36
|
+
entryCount,
|
|
37
|
+
} = getSitemapPaths({
|
|
34
38
|
pagesDir,
|
|
35
39
|
root: root ?? fileURLToPath(config.root),
|
|
36
40
|
});
|