@iterant/site-runtime 3.1.0 → 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.
@@ -50,7 +50,7 @@ runtime and says so.
50
50
 
51
51
  <!-- generated: available libraries -->
52
52
 
53
- _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.0._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.2._
54
54
 
55
55
  **Toolchain** (this package owns the version; do NOT declare these):
56
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.1.0",
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": {
@@ -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
+ }
@@ -207,6 +207,19 @@ if (fontProblems.length > 0) {
207
207
  exit(1);
208
208
  }
209
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
+
210
223
  // Island-import gate — registered sections never hydrate, so an island-grade
211
224
  // ui primitive imported there ships a dead widget with zero build signal.
212
225
  // The check (direct + barrel + any-depth relative imports; type-only imports
@@ -185,6 +185,12 @@ export function iterantStarter({
185
185
  // Tunnel/preview hostnames are random per boot, so hostname
186
186
  // allow-listing is impossible; access control lives outside the dev server.
187
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,
188
194
  },
189
195
  };
190
196
 
@@ -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
- schema: pageEntrySchema,
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
+ }