@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.
- package/README.md +30 -0
- package/bin/site-runtime.mjs +46 -0
- package/docs/runtime-contract.md +324 -0
- package/package.json +84 -0
- package/scripts/scan-bespoke-siblings.mjs +191 -0
- package/scripts/scan-copy.mjs +204 -0
- package/scripts/scan-island-imports.mjs +207 -0
- package/scripts/verify.mjs +283 -0
- package/src/components/seo-json.tsx +157 -0
- package/src/components/seo.tsx +294 -0
- package/src/config/preset.ts +198 -0
- package/src/content/collections.ts +71 -0
- package/src/content/schema.ts +239 -0
- package/src/index.ts +22 -0
- package/src/integrations/iterant-plugins.mjs +83 -0
- package/src/integrations/new-file-reload.mjs +95 -0
- package/src/integrations/preview-error-shell.mjs +145 -0
- package/src/layouts/LayoutCore.astro +182 -0
- package/src/layouts/layout-core.ts +141 -0
- package/src/lib/bespoke-pages.ts +60 -0
- package/src/lib/chrome-schemas.ts +266 -0
- package/src/lib/chrome.ts +23 -0
- package/src/lib/content-paths.ts +16 -0
- package/src/lib/content-values.ts +201 -0
- package/src/lib/hreflang.ts +123 -0
- package/src/lib/locales.ts +92 -0
- package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
- package/src/lib/sitemap/index.ts +19 -0
- package/src/lib/sitemap/routes.ts +141 -0
- package/src/lib/sitemap/shared.ts +95 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
- package/src/routes/UnderConstruction.astro +43 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/llms-txt.ts +68 -0
- package/src/routes/robots-txt.ts +56 -0
- 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
|
+
}
|