@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,157 @@
1
+ /**
2
+ * Consolidated JSON-LD rendering for schema.org structured data.
3
+ *
4
+ * Use this file in two ways:
5
+ * 1. Page-level schemas: pass `jsonLd` to <Layout />. This keeps the schema in
6
+ * the page shell with the rest of the SEO metadata.
7
+ * 2. Section-level schemas: render <SeoJson schema={...} /> inside the React
8
+ * section that owns the matching visible content, such as an FAQ section.
9
+ *
10
+ * Keep helpers limited to low-variance schemas this project renders from
11
+ * repeated local UI patterns. For customer-specific schema.org types, use
12
+ * schema-dts directly and pass the object to SeoJson.
13
+ *
14
+ * Example:
15
+ *
16
+ * const faqItems = [
17
+ * {
18
+ * question: "Can I use custom schemas?",
19
+ * answer: "Yes. Pass any schema.org JSON-LD object to SeoJson.",
20
+ * },
21
+ * ];
22
+ *
23
+ * <SeoJson
24
+ * schema={createFaqPageSchema(faqItems, {
25
+ * name: "Product FAQ",
26
+ * url: "https://example.com/pricing",
27
+ * })}
28
+ * />
29
+ *
30
+ * For schema.org types without a local helper, create a typed object with
31
+ * WithContext from schema-dts:
32
+ *
33
+ * const serviceJsonLd: WithContext<Service> = {
34
+ * "@context": "https://schema.org",
35
+ * "@type": "Service",
36
+ * name: "Website redesign",
37
+ * provider: { "@type": "Organization", name: "Example Studio" },
38
+ * };
39
+ *
40
+ * <SeoJson schema={serviceJsonLd} />
41
+ *
42
+ * SeoJson safely serializes the payload, removes duplicate per-node contexts,
43
+ * and combines multiple schemas into one @graph.
44
+ */
45
+ import type { FAQPage, Question, WithContext } from "schema-dts";
46
+
47
+ export type SeoJsonNode = object & {
48
+ "@context"?: "https://schema.org";
49
+ "@type"?: string | readonly string[];
50
+ };
51
+ export type SeoJsonGraph = object & {
52
+ "@context"?: "https://schema.org";
53
+ "@graph": readonly SeoJsonNode[];
54
+ };
55
+ export type SeoJsonSchema = SeoJsonNode | SeoJsonGraph;
56
+
57
+ type SeoJsonInput =
58
+ | SeoJsonSchema
59
+ | readonly SeoJsonSchema[]
60
+ | null
61
+ | false
62
+ | undefined;
63
+
64
+ interface SeoJsonProps {
65
+ schema?: SeoJsonInput;
66
+ schemas?: SeoJsonInput;
67
+ }
68
+
69
+ export interface FaqItem {
70
+ question: string;
71
+ answer: string;
72
+ }
73
+
74
+ export interface FaqSchemaOptions {
75
+ name?: string;
76
+ description?: string;
77
+ url?: string;
78
+ }
79
+
80
+ export function SeoJson({ schema, schemas }: SeoJsonProps) {
81
+ const nodes = normalizeSchemas(schema, schemas);
82
+
83
+ if (nodes.length === 0) return null;
84
+
85
+ return (
86
+ <script
87
+ type="application/ld+json"
88
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(nodes) }}
89
+ />
90
+ );
91
+ }
92
+
93
+ export function createFaqPageSchema(
94
+ items: readonly FaqItem[],
95
+ { name, description, url }: FaqSchemaOptions = {},
96
+ ): WithContext<FAQPage> {
97
+ return {
98
+ "@context": "https://schema.org",
99
+ "@type": "FAQPage",
100
+ ...(name && { name }),
101
+ ...(description && { description }),
102
+ ...(url && { url }),
103
+ mainEntity: items.map(
104
+ ({ question, answer }): Question => ({
105
+ "@type": "Question",
106
+ name: question,
107
+ acceptedAnswer: {
108
+ "@type": "Answer",
109
+ text: answer,
110
+ },
111
+ }),
112
+ ),
113
+ };
114
+ }
115
+
116
+ function normalizeSchemas(...inputs: readonly SeoJsonInput[]): SeoJsonNode[] {
117
+ const nodes: SeoJsonNode[] = [];
118
+
119
+ for (const input of inputs) {
120
+ if (!input) continue;
121
+
122
+ const schemas = Array.isArray(input) ? input : [input];
123
+
124
+ for (const value of schemas) {
125
+ if (isGraph(value)) {
126
+ nodes.push(...value["@graph"].map(stripContext));
127
+ } else {
128
+ nodes.push(stripContext(value));
129
+ }
130
+ }
131
+ }
132
+
133
+ return nodes;
134
+ }
135
+
136
+ function isGraph(value: SeoJsonSchema): value is SeoJsonGraph {
137
+ return "@graph" in value;
138
+ }
139
+
140
+ function stripContext(value: SeoJsonNode): SeoJsonNode {
141
+ const node = { ...(value as Record<string, unknown>) };
142
+ delete node["@context"];
143
+
144
+ return node as SeoJsonNode;
145
+ }
146
+
147
+ function serializeJsonLd(nodes: readonly SeoJsonNode[]): string {
148
+ const payload =
149
+ nodes.length === 1
150
+ ? {
151
+ "@context": "https://schema.org",
152
+ ...(nodes[0] as Record<string, unknown>),
153
+ }
154
+ : { "@context": "https://schema.org", "@graph": nodes };
155
+
156
+ return JSON.stringify(payload).replace(/</g, "\\u003c");
157
+ }
@@ -0,0 +1,294 @@
1
+ import { localeFromPath } from "../lib/locales";
2
+ import {
3
+ SeoJson,
4
+ type SeoJsonGraph,
5
+ type SeoJsonNode,
6
+ type SeoJsonSchema,
7
+ } from "./seo-json";
8
+
9
+ const SCHEMA_CONTEXT = "https://schema.org" as const;
10
+
11
+ // What a page IS, mirrored from the entry's `meta.pageType`
12
+ // (the page schema in ../content/schema.ts). Drives the page node's @type
13
+ // and, for product, a companion Product node. "page" is the absent-field
14
+ // default.
15
+ export type PageType = "page" | "article" | "product" | "about" | "contact";
16
+
17
+ const PAGE_NODE_TYPE: Record<PageType, string> = {
18
+ page: "WebPage",
19
+ article: "Article",
20
+ product: "WebPage",
21
+ about: "AboutPage",
22
+ contact: "ContactPage",
23
+ };
24
+
25
+ export interface OrganizationInfo {
26
+ logo?: string;
27
+ sameAs?: string[];
28
+ }
29
+
30
+ interface SEOProps {
31
+ title: string;
32
+ description?: string;
33
+ canonicalUrl?: string;
34
+ imageUrl?: string;
35
+ imageAlt?: string;
36
+ /** Legacy alias kept for existing shells; `pageType` wins when both are set. */
37
+ type?: "website" | "article";
38
+ pageType?: PageType;
39
+ datePublished?: string;
40
+ dateModified?: string;
41
+ noindex?: boolean;
42
+ siteName?: string;
43
+ organization?: OrganizationInfo;
44
+ jsonLd?: SeoJsonSchema | SeoJsonSchema[];
45
+ }
46
+
47
+ export function SEO({
48
+ title,
49
+ description,
50
+ canonicalUrl,
51
+ imageUrl,
52
+ imageAlt,
53
+ type = "website",
54
+ pageType,
55
+ datePublished,
56
+ dateModified,
57
+ noindex = false,
58
+ siteName,
59
+ organization,
60
+ jsonLd,
61
+ }: SEOProps) {
62
+ const resolvedSiteName = siteName ?? title;
63
+ const fullTitle =
64
+ title === resolvedSiteName ? title : `${title} | ${resolvedSiteName}`;
65
+ const robots = noindex ? "noindex, nofollow" : "index, follow";
66
+ const twitterCard = imageUrl ? "summary_large_image" : "summary";
67
+ const resolvedPageType: PageType =
68
+ pageType ?? (type === "article" ? "article" : "page");
69
+ const ogType = resolvedPageType === "article" ? "article" : "website";
70
+
71
+ const graph = noindex
72
+ ? []
73
+ : buildJsonLdGraph({
74
+ title,
75
+ fullTitle,
76
+ description,
77
+ canonicalUrl,
78
+ imageUrl,
79
+ pageType: resolvedPageType,
80
+ datePublished,
81
+ dateModified,
82
+ siteName: resolvedSiteName,
83
+ organization,
84
+ extras: jsonLd,
85
+ });
86
+
87
+ return (
88
+ <>
89
+ <title>{fullTitle}</title>
90
+ {description && <meta name="description" content={description} />}
91
+ <meta name="robots" content={robots} />
92
+ {canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
93
+
94
+ <meta property="og:type" content={ogType} />
95
+ <meta property="og:title" content={fullTitle} />
96
+ <meta property="og:site_name" content={resolvedSiteName} />
97
+ {description && <meta property="og:description" content={description} />}
98
+ {canonicalUrl && <meta property="og:url" content={canonicalUrl} />}
99
+ {imageUrl && <meta property="og:image" content={imageUrl} />}
100
+ {imageAlt && <meta property="og:image:alt" content={imageAlt} />}
101
+
102
+ <meta name="twitter:card" content={twitterCard} />
103
+ <meta name="twitter:title" content={fullTitle} />
104
+ {description && <meta name="twitter:description" content={description} />}
105
+ {imageUrl && <meta name="twitter:image" content={imageUrl} />}
106
+
107
+ <SeoJson schemas={graph} />
108
+ </>
109
+ );
110
+ }
111
+
112
+ export interface BuildGraphArgs {
113
+ title: string;
114
+ fullTitle: string;
115
+ description?: string;
116
+ canonicalUrl?: string;
117
+ imageUrl?: string;
118
+ pageType: PageType;
119
+ datePublished?: string;
120
+ dateModified?: string;
121
+ siteName: string;
122
+ organization?: OrganizationInfo;
123
+ extras?: SeoJsonSchema | SeoJsonSchema[];
124
+ }
125
+
126
+ // Deterministic schema.org graph for every indexable page. Emitted nodes:
127
+ // Organization + WebSite (site-scoped, @id-anchored at the origin), the page
128
+ // node (@type from pageType), BreadcrumbList derived from the canonical
129
+ // path, and a companion Product node on product pages. Page-specific extras
130
+ // (the `jsonLd` prop) merge in and suppress any default node sharing their
131
+ // @type, so a page can override without forking the defaults.
132
+ // Exported for tests.
133
+ export function buildJsonLdGraph({
134
+ title,
135
+ fullTitle,
136
+ description,
137
+ canonicalUrl,
138
+ imageUrl,
139
+ pageType,
140
+ datePublished,
141
+ dateModified,
142
+ siteName,
143
+ organization,
144
+ extras,
145
+ }: BuildGraphArgs): SeoJsonSchema[] {
146
+ const siteUrl = canonicalUrl ? new URL(canonicalUrl).origin : undefined;
147
+ const organizationId = siteUrl ? `${siteUrl}/#organization` : undefined;
148
+ const websiteId = siteUrl ? `${siteUrl}/#website` : undefined;
149
+ const breadcrumb = canonicalUrl
150
+ ? buildBreadcrumbList({ canonicalUrl, title, siteName })
151
+ : undefined;
152
+
153
+ const pageNode = {
154
+ "@context": SCHEMA_CONTEXT,
155
+ "@type": PAGE_NODE_TYPE[pageType],
156
+ ...(pageType === "article"
157
+ ? { headline: title, name: title }
158
+ : { name: fullTitle }),
159
+ ...(description && { description }),
160
+ ...(canonicalUrl && { url: canonicalUrl, "@id": canonicalUrl }),
161
+ ...(imageUrl && { image: imageUrl }),
162
+ ...(websiteId && { isPartOf: { "@id": websiteId } }),
163
+ ...(breadcrumb && { breadcrumb: { "@id": breadcrumbId(canonicalUrl!) } }),
164
+ ...(pageType === "article" && {
165
+ ...(datePublished && { datePublished }),
166
+ ...((dateModified ?? datePublished) && {
167
+ dateModified: dateModified ?? datePublished,
168
+ }),
169
+ ...(canonicalUrl && { mainEntityOfPage: canonicalUrl }),
170
+ ...(organizationId && {
171
+ publisher: { "@id": organizationId },
172
+ author: { "@id": organizationId },
173
+ }),
174
+ }),
175
+ };
176
+
177
+ const defaults = [
178
+ {
179
+ "@context": SCHEMA_CONTEXT,
180
+ "@type": "Organization",
181
+ ...(organizationId && { "@id": organizationId }),
182
+ name: siteName,
183
+ ...(siteUrl && { url: siteUrl }),
184
+ ...(organization?.logo && { logo: organization.logo }),
185
+ ...(organization?.sameAs?.length && { sameAs: organization.sameAs }),
186
+ },
187
+ {
188
+ "@context": SCHEMA_CONTEXT,
189
+ "@type": "WebSite",
190
+ ...(websiteId && { "@id": websiteId }),
191
+ name: siteName,
192
+ ...(siteUrl && { url: siteUrl }),
193
+ ...(organizationId && { publisher: { "@id": organizationId } }),
194
+ },
195
+ pageNode,
196
+ ...(breadcrumb ? [breadcrumb] : []),
197
+ ...(pageType === "product"
198
+ ? [
199
+ {
200
+ "@context": SCHEMA_CONTEXT,
201
+ "@type": "Product",
202
+ ...(canonicalUrl && { "@id": `${canonicalUrl}#product` }),
203
+ name: title,
204
+ ...(description && { description }),
205
+ ...(imageUrl && { image: imageUrl }),
206
+ brand: { "@type": "Brand", name: siteName },
207
+ },
208
+ ]
209
+ : []),
210
+ ];
211
+
212
+ const extraNodes = extras ? (Array.isArray(extras) ? extras : [extras]) : [];
213
+ const overridden = new Set(
214
+ extraNodes.flatMap((node) =>
215
+ isGraph(node) ? node["@graph"].map(getType) : [getType(node)],
216
+ ),
217
+ );
218
+
219
+ return [
220
+ ...defaults.filter((n) => !overridden.has(getType(n))),
221
+ ...extraNodes,
222
+ ];
223
+ }
224
+
225
+ function breadcrumbId(canonicalUrl: string): string {
226
+ return `${canonicalUrl}#breadcrumb`;
227
+ }
228
+
229
+ interface BreadcrumbArgs {
230
+ canonicalUrl: string;
231
+ title: string;
232
+ siteName: string;
233
+ }
234
+
235
+ // BreadcrumbList from the canonical path. The root crumb is the site (or the
236
+ // locale home for localized paths), intermediate crumbs are titleized
237
+ // segments, and the last crumb is the page's own title without a link (per
238
+ // Google's guidance the current page needs no item URL). The locale segment
239
+ // itself is never a crumb; it only shifts where the root points. Home pages
240
+ // (no segments) emit no breadcrumb.
241
+ function buildBreadcrumbList({
242
+ canonicalUrl,
243
+ title,
244
+ siteName,
245
+ }: BreadcrumbArgs) {
246
+ const url = new URL(canonicalUrl);
247
+ const segments = url.pathname.split("/").filter(Boolean);
248
+ const locale = localeFromPath(url.pathname);
249
+ const crumbSegments = locale ? segments.slice(1) : segments;
250
+ if (crumbSegments.length === 0) return undefined;
251
+
252
+ const rootPath = locale ? `/${locale}` : "/";
253
+ const items = [
254
+ { name: siteName, item: new URL(rootPath, url.origin).toString() },
255
+ ...crumbSegments.map((segment, index) => {
256
+ const isLast = index === crumbSegments.length - 1;
257
+ const path = `${rootPath === "/" ? "" : rootPath}/${crumbSegments
258
+ .slice(0, index + 1)
259
+ .join("/")}`;
260
+ return {
261
+ name: isLast ? title : titleizeSegment(segment),
262
+ ...(isLast ? {} : { item: new URL(path, url.origin).toString() }),
263
+ };
264
+ }),
265
+ ];
266
+
267
+ return {
268
+ "@context": SCHEMA_CONTEXT,
269
+ "@type": "BreadcrumbList",
270
+ "@id": breadcrumbId(canonicalUrl),
271
+ itemListElement: items.map((item, index) => ({
272
+ "@type": "ListItem",
273
+ position: index + 1,
274
+ ...item,
275
+ })),
276
+ };
277
+ }
278
+
279
+ function titleizeSegment(segment: string): string {
280
+ return segment
281
+ .split("-")
282
+ .filter(Boolean)
283
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
284
+ .join(" ");
285
+ }
286
+
287
+ function isGraph(node: SeoJsonSchema): node is SeoJsonGraph {
288
+ return "@graph" in node;
289
+ }
290
+
291
+ function getType(node: SeoJsonNode): string | undefined {
292
+ const type = node["@type"];
293
+ return typeof type === "string" ? type : type?.[0];
294
+ }
@@ -0,0 +1,198 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import cloudflare from "@astrojs/cloudflare";
4
+ import react from "@astrojs/react";
5
+ import tailwindcss from "@tailwindcss/vite";
6
+ import type { AstroUserConfig } from "astro";
7
+ import { sessionDrivers } from "astro/config";
8
+
9
+ import iterantPlugins from "../integrations/iterant-plugins.mjs";
10
+ import newFileReload from "../integrations/new-file-reload.mjs";
11
+ import previewErrorShell from "../integrations/preview-error-shell.mjs";
12
+ import { sitemapWithCustomPages } from "../lib/sitemap";
13
+
14
+ // The Astro configuration every brand site runs on, as one spreadable fragment:
15
+ //
16
+ // // astro.config.mjs
17
+ // import { defineConfig } from "astro/config";
18
+ // import { iterantStarter } from "@iterant/site-runtime/config";
19
+ //
20
+ // export default defineConfig({
21
+ // // Patched at deploy time by the platform — must remain a string literal.
22
+ // site: "https://example.com",
23
+ // ...iterantStarter(),
24
+ // });
25
+ //
26
+ // Adapter, integrations, vite tuning and sitemap wiring live here so a change to
27
+ // any of them reaches every brand as a version bump instead of a per-repo edit.
28
+ // Paths resolve against process.cwd(), the repo Astro is running in.
29
+
30
+ export interface IterantStarterOptions {
31
+ /**
32
+ * The site's canonical origin. Usually left OUT and kept as a literal in the
33
+ * repo's astro.config.mjs (the platform patches that literal at deploy time);
34
+ * pass it only when something else owns the value.
35
+ */
36
+ site?: string;
37
+ /**
38
+ * Dev-server host allow-list. `true` (the default) accepts any host: tunnel
39
+ * and preview hostnames are random per boot, so allow-listing is impossible
40
+ * and access control lives outside the dev server.
41
+ */
42
+ allowedHosts?: true | string[];
43
+ /**
44
+ * Page entry directory, relative to the project root, if a repo moved it.
45
+ * The same value goes to `createCollections` in src/content.config.ts: one
46
+ * knob, so the sitemap and the collection can never read different dirs.
47
+ */
48
+ pagesDir?: string;
49
+ /**
50
+ * Extensions for a diverged repo. These are the two keys a spread cannot
51
+ * express: extra `integrations` APPEND after the platform's, and `vite`
52
+ * merges one level deep with `plugins` appended. Everything else a repo needs
53
+ * to change it writes in its own `defineConfig` object after the spread,
54
+ * where plain spread precedence already does the right thing. Extending beats
55
+ * ejecting — an ejected config stops receiving platform changes.
56
+ */
57
+ overrides?: {
58
+ integrations?: NonNullable<AstroUserConfig["integrations"]>;
59
+ vite?: NonNullable<AstroUserConfig["vite"]>;
60
+ };
61
+ }
62
+
63
+ // The return type is inferred on purpose: it carries exactly the keys the
64
+ // preset sets. A full `AstroUserConfig` annotation makes every optional key
65
+ // (i18n above all) part of the spread the consumer hands `defineConfig`, and
66
+ // its `const` generic inference then resolves them against `never` and fails.
67
+ export function iterantStarter({
68
+ site,
69
+ allowedHosts = true,
70
+ pagesDir,
71
+ overrides,
72
+ }: 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.
76
+ const wranglerConfig = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]
77
+ .map((name) => join(process.cwd(), name))
78
+ .find((path) => existsSync(path));
79
+
80
+ // Separate vite cache dirs so `astro dev` and `astro build`/`check` don't conflict.
81
+ const astroCommand = process.argv
82
+ .slice(2)
83
+ .find((arg) => !arg.startsWith("-"));
84
+ const viteCacheDir =
85
+ astroCommand === "dev" || astroCommand === "preview"
86
+ ? "node_modules/.vite-dev"
87
+ : "node_modules/.vite-build";
88
+
89
+ const integrations: NonNullable<AstroUserConfig["integrations"]> = [
90
+ react(),
91
+ // For SSR-only dynamic routes, the page entries' routes are read from disk.
92
+ ...sitemapWithCustomPages({ pagesDir }),
93
+ // Dev-only: platform visual editor, gated on ?editor=1 in an iframe.
94
+ iterantPlugins(),
95
+ // Dev-only: swap 5xx error pages for the branded in-progress shell.
96
+ previewErrorShell(),
97
+ // Dev-only: broadcast a reload when a new src/ file matches no module.
98
+ newFileReload(),
99
+ ];
100
+
101
+ const vite: NonNullable<AstroUserConfig["vite"]> = {
102
+ cacheDir: viteCacheDir,
103
+ plugins: [tailwindcss()],
104
+ resolve: {
105
+ // Use react-dom/server.edge instead of react-dom/server.browser for React 19.
106
+ // Without this, MessageChannel from node:worker_threads needs to be polyfilled.
107
+ alias: import.meta.env.PROD
108
+ ? { "react-dom/server": "react-dom/server.edge" }
109
+ : undefined,
110
+ // Belt-and-braces against the "two React copies" invalid-hook crash: force a
111
+ // single physical copy even if a transitive dep pulls its own.
112
+ dedupe: ["react", "react-dom"],
113
+ },
114
+ ssr: {
115
+ noExternal: ["xxhash-wasm"],
116
+ ...(import.meta.env.PROD && {
117
+ resolve: {
118
+ conditions: ["workerd", "worker", "node"],
119
+ externalConditions: ["workerd", "worker", "node"],
120
+ },
121
+ }),
122
+ },
123
+ optimizeDeps: {
124
+ // Pre-bundle every client-side dep up front: discovering one
125
+ // mid-session re-optimizes with a new ?v hash, and a stale
126
+ // module graph then loads TWO React copies (invalid-hook
127
+ // crash in every island).
128
+ include: [
129
+ "react",
130
+ "react-dom",
131
+ "react-dom/client",
132
+ // The island SSR renderer: omitting it let the FIRST server-render of an
133
+ // island discover react-dom/server un-prebundled and trigger a mid-session
134
+ // re-optimize, briefly splitting React in two (the invalid-hook crash the
135
+ // comment above warns about — seen intermittently in navbar SSR).
136
+ "react-dom/server",
137
+ "react-dom/server.edge",
138
+ "react/jsx-runtime",
139
+ "react/jsx-dev-runtime",
140
+ "lucide-react",
141
+ "motion/react",
142
+ "clsx",
143
+ "tailwind-merge",
144
+ "class-variance-authority",
145
+ "@radix-ui/react-slot",
146
+ ],
147
+ },
148
+ server: {
149
+ strictPort: true,
150
+ // Tunnel/preview hostnames are random per boot, so hostname
151
+ // allow-listing is impossible; access control lives outside the dev server.
152
+ allowedHosts,
153
+ },
154
+ };
155
+
156
+ const base = {
157
+ ...(site !== undefined && { site }),
158
+ output: "server" as const,
159
+ trailingSlash: "never" as const,
160
+ // No Astro sessions: the in-memory driver stops the Cloudflare adapter from
161
+ // auto-provisioning a KV namespace per deploy.
162
+ session: {
163
+ driver: sessionDrivers.lruCache(),
164
+ },
165
+ adapter: cloudflare({
166
+ imageService: "compile",
167
+ ...(wranglerConfig && { configPath: wranglerConfig }),
168
+ }),
169
+ integrations,
170
+ vite,
171
+ // Port 4321 (Astro's default), NOT 3000: inside a Cloudflare Sandbox port
172
+ // 3000 is reserved by the sandbox control agent. host: true so the sandbox
173
+ // preview tunnel can reach the server.
174
+ server: {
175
+ port: 4321,
176
+ host: true,
177
+ open: false,
178
+ },
179
+ devToolbar: {
180
+ enabled: false,
181
+ },
182
+ };
183
+
184
+ if (!overrides) return base;
185
+
186
+ // Integrations and vite plugins APPEND: a repo adds to the platform's set, it
187
+ // never silently replaces it. The rest of `vite` merges one level deep.
188
+ const { integrations: extraIntegrations, vite: extraVite } = overrides;
189
+ return {
190
+ ...base,
191
+ integrations: [...integrations, ...(extraIntegrations ?? [])],
192
+ vite: {
193
+ ...vite,
194
+ ...extraVite,
195
+ plugins: [...(vite.plugins ?? []), ...(extraVite?.plugins ?? [])],
196
+ },
197
+ };
198
+ }