@iterant/site-runtime 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +30 -0
  2. package/bin/site-runtime.mjs +46 -0
  3. package/docs/runtime-contract.md +324 -0
  4. package/package.json +84 -0
  5. package/scripts/scan-bespoke-siblings.mjs +191 -0
  6. package/scripts/scan-copy.mjs +204 -0
  7. package/scripts/scan-island-imports.mjs +207 -0
  8. package/scripts/verify.mjs +283 -0
  9. package/src/components/seo-json.tsx +157 -0
  10. package/src/components/seo.tsx +294 -0
  11. package/src/config/preset.ts +198 -0
  12. package/src/content/collections.ts +71 -0
  13. package/src/content/schema.ts +239 -0
  14. package/src/index.ts +22 -0
  15. package/src/integrations/iterant-plugins.mjs +83 -0
  16. package/src/integrations/new-file-reload.mjs +95 -0
  17. package/src/integrations/preview-error-shell.mjs +145 -0
  18. package/src/layouts/LayoutCore.astro +182 -0
  19. package/src/layouts/layout-core.ts +141 -0
  20. package/src/lib/bespoke-pages.ts +60 -0
  21. package/src/lib/chrome-schemas.ts +266 -0
  22. package/src/lib/chrome.ts +23 -0
  23. package/src/lib/content-paths.ts +16 -0
  24. package/src/lib/content-values.ts +201 -0
  25. package/src/lib/hreflang.ts +123 -0
  26. package/src/lib/locales.ts +92 -0
  27. package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
  28. package/src/lib/sitemap/index.ts +19 -0
  29. package/src/lib/sitemap/routes.ts +141 -0
  30. package/src/lib/sitemap/shared.ts +95 -0
  31. package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
  32. package/src/routes/UnderConstruction.astro +43 -0
  33. package/src/routes/index.ts +11 -0
  34. package/src/routes/llms-txt.ts +68 -0
  35. package/src/routes/robots-txt.ts +56 -0
  36. package/src/version.ts +9 -0
@@ -0,0 +1,71 @@
1
+ import { defineCollection } from "astro:content";
2
+ import { glob } from "astro/loaders";
3
+
4
+ import {
5
+ DEFAULT_CHROME_DIR,
6
+ DEFAULT_PAGES_DIR,
7
+ loaderBase,
8
+ } from "../lib/content-paths";
9
+ import { entryIdFromFile } from "../lib/locales";
10
+ import { createContentSchemas, type ContentSchemaOptions } from "./schema";
11
+
12
+ // The `pages` + `chrome` collections a brand site runs on. Astro requires the
13
+ // collection definition to live in the repo's own src/content.config.ts, so
14
+ // that file stays a shim over this factory:
15
+ //
16
+ // import { createCollections } from "@iterant/site-runtime/content";
17
+ // import { CHROME_COMPONENT_PROPS } from "@iterant/site-runtime/chrome-schemas";
18
+ // import { REGISTERED_SECTION_PROPS } from "./lib/section-schemas";
19
+ //
20
+ // export const collections = createCollections({
21
+ // registeredSectionProps: REGISTERED_SECTION_PROPS,
22
+ // chromeComponentProps: CHROME_COMPONENT_PROPS,
23
+ // });
24
+ //
25
+ // The grammar, the entry shape and the loader wiring are platform-owned; which
26
+ // section and chrome types exist stays brand-owned.
27
+
28
+ export interface CreateCollectionsOptions extends ContentSchemaOptions {
29
+ /** Page entry directory, relative to the project root. A repo that moves it
30
+ * must pass the same value to `iterantStarter`, or the sitemap and the
31
+ * collection read different directories. */
32
+ pagesDir?: string;
33
+ /** Directory holding chrome.json and its locale siblings. */
34
+ chromeDir?: string;
35
+ }
36
+
37
+ export function createCollections({
38
+ registeredSectionProps,
39
+ chromeComponentProps,
40
+ pagesDir = DEFAULT_PAGES_DIR,
41
+ chromeDir = DEFAULT_CHROME_DIR,
42
+ }: CreateCollectionsOptions) {
43
+ const { pageEntrySchema, chromeEntrySchema } = createContentSchemas({
44
+ registeredSectionProps,
45
+ chromeComponentProps,
46
+ });
47
+
48
+ const pages = defineCollection({
49
+ // generateId preserves dots (the default slugifies `home.es` → `homees`),
50
+ // so locale sibling ids keep the platform's `<page>.<locale>` grammar.
51
+ loader: glob({
52
+ pattern: "**/*.json",
53
+ base: loaderBase(pagesDir),
54
+ generateId: ({ entry }) => entryIdFromFile(entry),
55
+ }),
56
+ schema: pageEntrySchema,
57
+ });
58
+
59
+ const chrome = defineCollection({
60
+ // The base chrome plus its locale siblings (chrome.es.json); LayoutCore
61
+ // resolves per request path with fallback to the base.
62
+ loader: glob({
63
+ pattern: ["chrome.json", "chrome.*.json"],
64
+ base: loaderBase(chromeDir),
65
+ generateId: ({ entry }) => entryIdFromFile(entry),
66
+ }),
67
+ schema: chromeEntrySchema,
68
+ });
69
+
70
+ return { pages, chrome };
71
+ }
@@ -0,0 +1,239 @@
1
+ import { z } from "astro/zod";
2
+
3
+ import { chromeIdIssues } from "../lib/chrome-schemas";
4
+ import { contentPropsSchema } from "../lib/content-values";
5
+ import { translationProvenanceSchema } from "../lib/locales";
6
+
7
+ // Page content lives HERE, as JSON — components render it, they never
8
+ // contain it. One entry per page; `components` is the ordered list of
9
+ // sections on the page. All visible copy sits in each component's `props`
10
+ // in the content-value grammar (../lib/content-values.ts).
11
+ //
12
+ // {
13
+ // "route": "/pricing",
14
+ // "meta": { "title": "Pricing — Brand", "description": "For SEO" },
15
+ // "components": [
16
+ // {
17
+ // "id": "pricing-hero",
18
+ // "type": "hero",
19
+ // "props": { "heading": { "type": "text", "value": "…" } }
20
+ // }
21
+ // ]
22
+ // }
23
+ //
24
+ // - `id` is a stable kebab-case anchor, unique per page. The visual editor
25
+ // and translation address content THROUGH it — never rename an id after
26
+ // creation.
27
+ // - `mode` (default "registry"): registry pages render through
28
+ // src/pages/[...slug].astro + the brand's section registry, so every `type`
29
+ // must be registered. Bespoke pages set `"mode": "bespoke"` — their
30
+ // components are the page's own React components, and a bespoke .astro
31
+ // shell threads the entry into the page.
32
+ // - `draft: true` renders in dev only.
33
+ // - The sitemap picks `route` up automatically
34
+ // (../lib/sitemap/get-sitemap-paths.ts reads the pages directory from disk).
35
+ //
36
+ // WHICH section and chrome types exist is brand-owned: the repo passes its own
37
+ // registries into these factories. The grammar around them is platform-owned.
38
+
39
+ /**
40
+ * A brand-owned props registry: component type → the zod schema its `props`
41
+ * must satisfy. `REGISTERED_SECTION_PROPS` and `CHROME_COMPONENT_PROPS` in a
42
+ * brand repo are both this shape.
43
+ */
44
+ export type PropsSchemaRegistry = Record<string, z.ZodTypeAny>;
45
+
46
+ export interface ContentSchemaOptions {
47
+ /** The repo's `REGISTERED_SECTION_PROPS` (src/lib/section-schemas.ts). */
48
+ registeredSectionProps: PropsSchemaRegistry;
49
+ /** The repo's `CHROME_COMPONENT_PROPS` (re-exported from this package). */
50
+ chromeComponentProps: PropsSchemaRegistry;
51
+ }
52
+
53
+ const isoDateSchema = z
54
+ .string()
55
+ .regex(
56
+ /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)?$/,
57
+ "expected an ISO date (2026-08-09) or ISO 8601 datetime",
58
+ );
59
+
60
+ /**
61
+ * The page and chrome entry schemas for one brand's registries. Both
62
+ * collections share a single component schema, so a chrome entry is held to
63
+ * the same grammar as a page section and additionally to the chrome mount
64
+ * contract (chromeIdIssues + CHROME_COMPONENT_PROPS).
65
+ */
66
+ export function createContentSchemas({
67
+ registeredSectionProps,
68
+ chromeComponentProps,
69
+ }: ContentSchemaOptions) {
70
+ const isRegisteredSection = (type: string) => type in registeredSectionProps;
71
+ const isChromeComponent = (type: string) => type in chromeComponentProps;
72
+
73
+ const componentSchema = z
74
+ .object({
75
+ id: z
76
+ .string()
77
+ .regex(
78
+ /^[a-z0-9][a-z0-9-]*$/,
79
+ 'component ids are stable kebab-case anchors, e.g. "pricing-hero"',
80
+ ),
81
+ type: z.string().min(1),
82
+ order: z.number().int().optional(), // defaults to array position
83
+ locked: z.boolean().optional(),
84
+ hidden: z.boolean().optional(),
85
+ // Non-rendering, platform-owned (figma provenance, warnings); never read by the renderer.
86
+ metadata: z.record(z.string(), z.unknown()).optional(),
87
+ props: contentPropsSchema,
88
+ })
89
+ .strict()
90
+ .superRefine((component, ctx) => {
91
+ if (isRegisteredSection(component.type)) {
92
+ const result = registeredSectionProps[component.type].safeParse(
93
+ component.props,
94
+ );
95
+ if (!result.success) {
96
+ for (const issue of result.error.issues) {
97
+ ctx.addIssue({ ...issue, path: ["props", ...issue.path] });
98
+ }
99
+ }
100
+ }
101
+ });
102
+
103
+ type Component = z.infer<typeof componentSchema>;
104
+
105
+ const componentListSchema = (
106
+ components: Component[],
107
+ ctx: z.RefinementCtx,
108
+ ) => {
109
+ const seen = new Set<string>();
110
+ components.forEach((component, index) => {
111
+ if (seen.has(component.id)) {
112
+ ctx.addIssue({
113
+ code: "custom",
114
+ path: [index, "id"],
115
+ message: `duplicate component id "${component.id}" — ids must be unique per entry`,
116
+ });
117
+ }
118
+ seen.add(component.id);
119
+ });
120
+ };
121
+
122
+ const pageEntrySchema = z
123
+ .object({
124
+ version: z.number().int().default(1),
125
+ schemaVersion: z.string().default("1.0"),
126
+ route: z
127
+ .string()
128
+ .regex(/^\/[a-z0-9\-/]*$/, "route must start with / and be lowercase"),
129
+ // Page display name (distinct from the SEO meta.title). Optional and
130
+ // additive; the head <title> falls back to it when meta.title is empty.
131
+ title: z.string().optional(),
132
+ mode: z.enum(["registry", "bespoke"]).default("registry"),
133
+ // Chrome opt-out (starter 2.13.0): default true renders the site-level
134
+ // navbar/footer from chrome.json as before. Set false on a page whose
135
+ // design ships its own nav/footer (a full-design import) so the layout
136
+ // mounts no chrome. Additive — existing entries omit it and keep chrome.
137
+ chrome: z.boolean().default(true),
138
+ meta: z
139
+ .object({
140
+ title: z.string(),
141
+ description: z.string().optional(),
142
+ // Social/share image. src must be an absolute URL (crawlers 403 on
143
+ // signed S3 URLs — publish CDN URLs only). assetId links back to the
144
+ // brand asset; alt is the accessible/og:image:alt text.
145
+ ogImage: z
146
+ .object({
147
+ src: z.string().url(),
148
+ assetId: z.string().optional(),
149
+ alt: z.string().optional(),
150
+ })
151
+ .strict()
152
+ .optional(),
153
+ // What the page IS, for structured data (starter 2.17.0). The layout
154
+ // resolves the entry by route and emits the matching schema.org node
155
+ // deterministically (see ../components/seo.tsx) — never hand-author
156
+ // JSON-LD for types this covers. Absent means a plain WebPage.
157
+ pageType: z
158
+ .enum(["page", "article", "product", "about", "contact"])
159
+ .optional(),
160
+ // ISO dates (YYYY-MM-DD or full ISO 8601) for article-shaped pages;
161
+ // emitted as Article datePublished/dateModified. Set datePublished
162
+ // when the page first ships; touch dateModified on substantial
163
+ // content updates, not routine tweaks.
164
+ datePublished: isoDateSchema.optional(),
165
+ dateModified: isoDateSchema.optional(),
166
+ })
167
+ .strict(),
168
+ draft: z.boolean().default(false),
169
+ components: z.array(componentSchema).superRefine(componentListSchema),
170
+ // Locale siblings (<page>.<locale>.json, starter 2.8.0) carry per-leaf
171
+ // translation provenance. Platform-maintained, renderer-ignored — kept
172
+ // loose on purpose so agent-side evolution never fails a brand build.
173
+ _translation: translationProvenanceSchema.optional(),
174
+ })
175
+ .strict()
176
+ .superRefine((page, ctx) => {
177
+ if (page.mode !== "registry") return;
178
+ page.components.forEach((component, index) => {
179
+ if (!isRegisteredSection(component.type)) {
180
+ ctx.addIssue({
181
+ code: "custom",
182
+ path: ["components", index, "type"],
183
+ message: `"${component.type}" is not a registered section type (registered: ${Object.keys(registeredSectionProps).join(", ")}). Bespoke components need "mode": "bespoke" plus a bespoke page shell.`,
184
+ });
185
+ }
186
+ });
187
+ });
188
+
189
+ // Site-level chrome copy (src/content/chrome.json): the navbar and footer
190
+ // are structural — built once per brand, rendered by the layout on every
191
+ // page — but their COPY (link labels, hrefs, footer text) is copy like any
192
+ // other, so it lives here in the same component grammar. Chrome components
193
+ // read their props via ../lib/chrome.ts.
194
+ // Validate a chrome component's props against its chrome-specific schema.
195
+ // Mirrors componentSchema's registered-section superRefine, but keyed off
196
+ // chromeComponentProps (navbar/footer) — NOT registeredSectionProps, so
197
+ // chrome types never become page-insertable registry sections. chrome.json
198
+ // renders ONLY navbar + footer (the layout mounts those two ids), so any
199
+ // other id or an id/type mismatch is a hard error (chromeIdIssues) — extra
200
+ // bands belong in page-adjacent sections, never here where nothing would
201
+ // mount them.
202
+ const chromeComponentListSchema = (
203
+ components: Component[],
204
+ ctx: z.RefinementCtx,
205
+ ) => {
206
+ componentListSchema(components, ctx);
207
+ for (const issue of chromeIdIssues(components)) {
208
+ ctx.addIssue({
209
+ code: "custom",
210
+ path: [issue.index, "id"],
211
+ message: issue.message,
212
+ });
213
+ }
214
+ components.forEach((component, index) => {
215
+ if (!isChromeComponent(component.type)) return;
216
+ const result = chromeComponentProps[component.type].safeParse(
217
+ component.props,
218
+ );
219
+ if (!result.success) {
220
+ for (const issue of result.error.issues) {
221
+ ctx.addIssue({ ...issue, path: [index, "props", ...issue.path] });
222
+ }
223
+ }
224
+ });
225
+ };
226
+
227
+ const chromeEntrySchema = z
228
+ .object({
229
+ components: z
230
+ .array(componentSchema)
231
+ .superRefine(chromeComponentListSchema),
232
+ _translation: translationProvenanceSchema.optional(),
233
+ })
234
+ .strict();
235
+
236
+ return { componentSchema, pageEntrySchema, chromeEntrySchema };
237
+ }
238
+
239
+ export type ContentSchemas = ReturnType<typeof createContentSchemas>;
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // The package's pure surface: the content grammar, the collection schema
2
+ // factories, locale/hreflang machinery, the chrome prop schemas, the bespoke
3
+ // page helpers, the layout's data types, and the runtime version. Nothing here
4
+ // imports an `astro:*` virtual module, so this entry is safe to import outside
5
+ // an Astro build (the platform agent validates brand content through it).
6
+ //
7
+ // Astro-bound surfaces have their own entries: `/content` (collections),
8
+ // `/layout`, `/seo`, `/routes`, `/sitemap`, `/config`, `/integrations/*`.
9
+ export * from "./content/schema";
10
+ export * from "./layouts/layout-core";
11
+ export * from "./lib/bespoke-pages";
12
+ export * from "./lib/chrome-schemas";
13
+ export * from "./lib/content-paths";
14
+ export * from "./lib/content-values";
15
+ export * from "./lib/hreflang";
16
+ export * from "./lib/locales";
17
+ export * from "./version";
18
+ export type {
19
+ BuildGraphArgs,
20
+ OrganizationInfo,
21
+ PageType,
22
+ } from "./components/seo";
@@ -0,0 +1,83 @@
1
+ // @ts-check
2
+
3
+ // Central-plugin loader (starter ≥2.4.0) — the ONLY plugin code in brand
4
+ // repos. Platform plugins (visual editor, dev-soft-reload, …) are served by
5
+ // plugins.iterant.ai and CI/CD'd from the monorepo
6
+ // (.github/workflows/plugins-deploy.yml): promoting a plugin there reaches
7
+ // every brand's next preview load, with zero brand-repo commits. This file
8
+ // replaces the vendored-IIFE mechanism (integrations/visual-editor-dev.mjs +
9
+ // vendor/, retired in 2.4.0) and is deliberately too dumb to need changing.
10
+ //
11
+ // Dev-only. Two jobs:
12
+ //
13
+ // 1. Rebroadcast Vite HMR lifecycle as DOM events — remote plugins are not in
14
+ // the dev module graph, so they cannot touch import.meta.hot:
15
+ // - `vite:beforeFullReload` → cancelable `iterant:before-full-reload`.
16
+ // A plugin calling preventDefault() makes this loader throw inside the
17
+ // hot callback (vitejs/vite#6695), which cancels Vite's
18
+ // location.reload() — how dev-soft-reload morphs instead of flashing.
19
+ // - `vite:afterUpdate` → `vite:hmr-complete` (the visual-editor runtime's
20
+ // existing overlay-reapply contract).
21
+ //
22
+ // 2. Inject the plugin script tags: dev-soft-reload on every page;
23
+ // visual-editor only inside an iframe with ?editor=1 (and production
24
+ // builds contain zero plugin bytes — the loader is dev-command-gated and
25
+ // scripts/verify.mjs keeps enforcing the editor markers after every
26
+ // build).
27
+ //
28
+ // Fails soft by construction: plugin host unreachable → plain preview with
29
+ // native reloads and no editor; the page itself never breaks.
30
+
31
+ const BOOTSTRAP = `
32
+ const HOST =
33
+ import.meta.env.PUBLIC_ITERANT_PLUGINS_HOST || "https://plugins.iterant.ai";
34
+ const STARTER =
35
+ document.querySelector('meta[name="it-astro-starter-version"]')?.content ??
36
+ "";
37
+
38
+ function loadPlugin(name, id) {
39
+ if (document.getElementById(id)) return;
40
+ const script = document.createElement("script");
41
+ script.id = id;
42
+ script.src =
43
+ HOST + "/" + name + "/stable/plugin.js?starter=" +
44
+ encodeURIComponent(STARTER);
45
+ document.head.appendChild(script);
46
+ }
47
+
48
+ loadPlugin("dev-soft-reload", "__iterant-dev-soft-reload");
49
+ if (
50
+ window.parent !== window &&
51
+ new URLSearchParams(window.location.search).get("editor") === "1"
52
+ ) {
53
+ loadPlugin("visual-editor", "__visual-editor");
54
+ }
55
+
56
+ if (import.meta.hot) {
57
+ import.meta.hot.on("vite:beforeFullReload", () => {
58
+ const event = new CustomEvent("iterant:before-full-reload", {
59
+ cancelable: true,
60
+ });
61
+ window.dispatchEvent(event);
62
+ if (event.defaultPrevented) {
63
+ throw "[iterant-plugins] full reload handled by a plugin";
64
+ }
65
+ });
66
+ import.meta.hot.on("vite:afterUpdate", () => {
67
+ window.dispatchEvent(new CustomEvent("vite:hmr-complete"));
68
+ });
69
+ }
70
+ `;
71
+
72
+ /** @returns {import("astro").AstroIntegration} */
73
+ export default function iterantPlugins() {
74
+ return {
75
+ name: "iterant-plugins",
76
+ hooks: {
77
+ "astro:config:setup": ({ command, injectScript }) => {
78
+ if (command !== "dev") return;
79
+ injectScript("page", BOOTSTRAP);
80
+ },
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ import { utimes } from "node:fs";
3
+
4
+ // Dev-only new-file hot-reload (2026-07-23). Vite's HMR only reacts to files
5
+ // already present in a module graph. A freshly CREATED component under src/
6
+ // matches no module node — the import-analysis recovery that would pull it in
7
+ // is client-graph-only (`if (ssr) return` in Vite's import-analysis path), so
8
+ // the `add` event broadcasts nothing and the preview never refreshes until a
9
+ // manual reload. Edits to existing modules are unaffected.
10
+ //
11
+ // This environment-API `hotUpdate` plugin catches any create/delete under src/
12
+ // that resolved to zero modules and broadcasts a full-reload. The dev-soft-
13
+ // reload preview plugin turns that into a soft morph instead of a flash, and a
14
+ // route caught mid-build (5xx) self-heals on the next event.
15
+ //
16
+ // Broadcasts are coalesced on a short debounce so a bulk write storm (an
17
+ // import scaffolding several files, a rename, a branch checkout) settles into
18
+ // one reload rather than one per file. Assets that can't affect a rendered
19
+ // module are skipped.
20
+ //
21
+ // The same blind spot starves Tailwind (2026-07-30): @tailwindcss/vite only
22
+ // re-scans the filesystem when a CSS root's build dependencies change mtime,
23
+ // so a component created mid-session contributes no new utility classes (its
24
+ // novel arbitrary values render unstyled) until someone edits a stylesheet.
25
+ // Before broadcasting, this plugin therefore bumps the mtime of every project
26
+ // CSS module, driving the same invalidate-and-rescan path as a real edit.
27
+
28
+ const RELOAD_DEBOUNCE_MS = 100;
29
+
30
+ // Obvious binary/asset extensions that never resolve to a source module —
31
+ // dropping an image or font under src/ shouldn't trigger a reload.
32
+ const NON_SOURCE =
33
+ /\.(png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|mp4|webm|mov|pdf|zip)$/i;
34
+
35
+ /** @returns {import("astro").AstroIntegration} */
36
+ export default function newFileReload() {
37
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
38
+ let timer;
39
+ return {
40
+ name: "new-file-reload",
41
+ hooks: {
42
+ "astro:config:setup": ({ command, updateConfig }) => {
43
+ if (command !== "dev") return;
44
+ updateConfig({
45
+ vite: {
46
+ plugins: [
47
+ {
48
+ name: "iterant-new-file-reload",
49
+ hotUpdate({ type, file, modules, server }) {
50
+ // A create/delete that resolved to a module is already
51
+ // handled by the module-graph HMR path.
52
+ if (type === "update" || modules.length > 0) return;
53
+ // hotUpdate fires once per environment (client + ssr); act on
54
+ // the client pass only so one event broadcasts one reload.
55
+ if (this.environment.name !== "client") return;
56
+ if (!file.includes("/src/")) return;
57
+ if (NON_SOURCE.test(file)) return;
58
+ // Coalesce a burst of qualifying events into a single reload:
59
+ // reset the timer on each one and fire once the tree settles.
60
+ clearTimeout(timer);
61
+ timer = setTimeout(() => {
62
+ // Tailwind v4 only re-scans the filesystem when a CSS
63
+ // root's build dependencies change mtime (requiresBuild in
64
+ // @tailwindcss/vite); a brand-new source file changes no
65
+ // watched module, so its novel classes (arbitrary values
66
+ // especially) stay missing from the generated CSS until
67
+ // something touches the stylesheet. Bump the mtime of every
68
+ // CSS module in the graph so the watcher runs the same
69
+ // invalidate-and-rescan path as a real edit.
70
+ const touched = new Set();
71
+ for (const environment of Object.values(
72
+ server.environments,
73
+ )) {
74
+ for (const mod of environment.moduleGraph.idToModuleMap.values()) {
75
+ const cssFile = mod.file;
76
+ if (!cssFile || !cssFile.endsWith(".css")) continue;
77
+ if (cssFile.includes("/node_modules/")) continue;
78
+ if (touched.has(cssFile)) continue;
79
+ touched.add(cssFile);
80
+ const now = new Date();
81
+ utimes(cssFile, now, now, () => {});
82
+ }
83
+ }
84
+ server.ws.send({ type: "full-reload", path: "*" });
85
+ }, RELOAD_DEBOUNCE_MS);
86
+ return [];
87
+ },
88
+ },
89
+ ],
90
+ },
91
+ });
92
+ },
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,145 @@
1
+ // @ts-check
2
+
3
+ // Dev-only branded fallback for broken routes (2026-07-06): mid-build there
4
+ // is a window where a route exists but its page component doesn't (the shell
5
+ // lands before page.tsx) or is mid-edit with a syntax error — and the Astro
6
+ // dev server serves its raw stack-trace error page into the customer's
7
+ // preview iframe. Astro middleware can't catch this class (the module fails
8
+ // to load BEFORE middleware runs), so this intercepts at the HTTP layer:
9
+ // buffer HTML responses and swap any 5xx body for the starter-owned
10
+ // /under-construction shell (navbar + footer + "in progress"). The 5xx
11
+ // status is preserved on purpose — verify probes and the save gate must
12
+ // still see the failure; only the human-facing body changes.
13
+
14
+ // vite is astro's dependency, not this package's, so the dev-server type is
15
+ // derived from the hook astro hands it to. One vite in the type graph, whichever
16
+ // version the installed astro ships.
17
+ /**
18
+ * @typedef {Parameters<
19
+ * NonNullable<import("astro").AstroIntegration["hooks"]["astro:server:setup"]>
20
+ * >[0]["server"]} ViteDevServer
21
+ */
22
+
23
+ const FALLBACK_PATH = "/under-construction";
24
+
25
+ // Served when /under-construction itself can't render (e.g. Layout broken).
26
+ const MINIMAL_FALLBACK = `<!doctype html>
27
+ <html lang="en"><head><meta charset="utf-8"><title>Page in progress</title></head>
28
+ <body style="display:flex;min-height:100vh;align-items:center;justify-content:center;font-family:system-ui,sans-serif">
29
+ <p>This page is in progress — check back in a moment.</p>
30
+ </body></html>`;
31
+
32
+ /** @returns {import("astro").AstroIntegration} */
33
+ export default function previewErrorShell() {
34
+ return {
35
+ name: "preview-error-shell",
36
+ hooks: {
37
+ "astro:config:setup": ({ command, updateConfig }) => {
38
+ if (command !== "dev") return;
39
+ updateConfig({
40
+ vite: {
41
+ plugins: [
42
+ {
43
+ name: "preview-error-shell",
44
+ configureServer(server) {
45
+ server.middlewares.use((req, res, next) => {
46
+ const accept = String(req.headers.accept ?? "");
47
+ if (
48
+ req.method !== "GET" ||
49
+ !accept.includes("text/html") ||
50
+ String(req.url ?? "").startsWith(FALLBACK_PATH)
51
+ ) {
52
+ return next();
53
+ }
54
+ intercept(server, res);
55
+ next();
56
+ });
57
+ // Astro's own dev handler registers earlier in the connect
58
+ // stack and ends the response before later layers run —
59
+ // hoist this layer to the front so it wraps every HTML
60
+ // response (the accept filter keeps it off asset requests).
61
+ const layer = server.middlewares.stack.pop();
62
+ if (layer) server.middlewares.stack.unshift(layer);
63
+ },
64
+ },
65
+ ],
66
+ },
67
+ });
68
+ },
69
+ },
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Buffer the response; replay it verbatim unless it resolves to a 5xx, in
75
+ * which case serve the branded fallback body instead. Buffering trades dev
76
+ * streaming for the swap — fine for a preview.
77
+ *
78
+ * @param {ViteDevServer} server
79
+ * @param {import("node:http").ServerResponse} res
80
+ */
81
+ function intercept(server, res) {
82
+ /** @type {Array<string | Buffer>} */
83
+ const chunks = [];
84
+ /** @type {unknown[] | null} */
85
+ let headArgs = null;
86
+ const original = {
87
+ writeHead: res.writeHead.bind(res),
88
+ write: res.write.bind(res),
89
+ end: res.end.bind(res),
90
+ };
91
+ res.writeHead = /** @type {any} */ (
92
+ (/** @type {unknown[]} */ ...args) => {
93
+ headArgs = args;
94
+ return res;
95
+ }
96
+ );
97
+ res.write = /** @type {any} */ (
98
+ (/** @type {any} */ chunk) => {
99
+ if (chunk) chunks.push(chunk);
100
+ return true;
101
+ }
102
+ );
103
+ res.end = /** @type {any} */ (
104
+ (/** @type {any} */ chunk) => {
105
+ if (chunk && typeof chunk !== "function") chunks.push(chunk);
106
+ const status = headArgs
107
+ ? Number(headArgs[0]) || res.statusCode
108
+ : res.statusCode;
109
+ if (status < 500) {
110
+ res.writeHead = original.writeHead;
111
+ res.write = original.write;
112
+ res.end = original.end;
113
+ if (headArgs) original.writeHead(.../** @type {[number]} */ (headArgs));
114
+ for (const c of chunks) original.write(c);
115
+ return original.end();
116
+ }
117
+ void serveFallback(server, original, status);
118
+ return res;
119
+ }
120
+ );
121
+ }
122
+
123
+ /**
124
+ * @param {ViteDevServer} server
125
+ * @param {{ writeHead: Function, write: Function, end: Function }} original
126
+ * @param {number} status
127
+ */
128
+ async function serveFallback(server, original, status) {
129
+ let html = MINIMAL_FALLBACK;
130
+ try {
131
+ const port = server.config.server.port;
132
+ const resp = await fetch(`http://127.0.0.1:${port}${FALLBACK_PATH}`, {
133
+ headers: { accept: "text/html" },
134
+ });
135
+ if (resp.ok) html = await resp.text();
136
+ } catch {
137
+ // fall through to the minimal shell
138
+ }
139
+ original.writeHead(status, {
140
+ "content-type": "text/html",
141
+ "x-preview-fallback": "1",
142
+ });
143
+ original.write(html);
144
+ original.end();
145
+ }