@contentrain/emitter-astro 0.14.5 → 0.16.0

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/dist/index.mjs CHANGED
@@ -1,8 +1,296 @@
1
- import { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, DICTIONARY_TITLE_FIELD, LEGACY_CSS_LAYER } from "@contentrain/types";
1
+ import { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, DICTIONARY_TITLE_FIELD, LEGACY_CSS_LAYER, SEO_PROVIDERS } from "@contentrain/types";
2
2
  //#region src/types.ts
3
3
  /** Default collection name when a `single` route does not name one. */
4
4
  const DEFAULT_COLLECTION = "posts";
5
5
  //#endregion
6
+ //#region src/images.ts
7
+ const DEFAULT_IMAGE_WIDTHS = [
8
+ 480,
9
+ 768,
10
+ 1024,
11
+ 1600
12
+ ];
13
+ function imagesEnabled(options) {
14
+ return options.images?.enabled !== false;
15
+ }
16
+ /**
17
+ * The hosts the site may optimize: the producer's list plus the runtime host,
18
+ * because migrated media is rehosted there. Deduplicated, stable order.
19
+ */
20
+ function imagePatterns(options, runtime) {
21
+ const out = [...options.images?.remotePatterns ?? []];
22
+ if (runtime?.base_url) try {
23
+ const url = new URL(runtime.base_url);
24
+ out.push({
25
+ protocol: url.protocol === "http:" ? "http" : "https",
26
+ hostname: url.hostname
27
+ });
28
+ } catch {}
29
+ const seen = /* @__PURE__ */ new Set();
30
+ return out.filter((p) => {
31
+ const key = `${p.protocol ?? "https"}|${p.hostname}|${p.pathname ?? ""}`;
32
+ if (seen.has(key)) return false;
33
+ seen.add(key);
34
+ return true;
35
+ });
36
+ }
37
+ /** The `image` block for astro.config.mjs — Astro enforces the same allow-list. */
38
+ function astroImageConfig(patterns) {
39
+ if (patterns.length === 0) return null;
40
+ return ` image: { remotePatterns: [${patterns.map((p) => {
41
+ const parts = [`protocol: ${JSON.stringify(p.protocol ?? "https")}`, `hostname: ${JSON.stringify(p.hostname)}`];
42
+ if (p.pathname) parts.push(`pathname: ${JSON.stringify(`${p.pathname.replace(/\/?$/, "/")}**`)}`);
43
+ return `{ ${parts.join(", ")} }`;
44
+ }).join(", ")}] },`;
45
+ }
46
+ const IMAGES_TS = `// Emitted by @contentrain/emitter-astro — image pass over content HTML.
47
+ // Pure: the optimizer is injected (see optimize-images.ts), so this file has
48
+ // no Astro dependency and never breaks a page — an image the optimizer
49
+ // cannot handle keeps its original markup.
50
+
51
+ export interface OptimizedImage {
52
+ src: string
53
+ srcset?: string
54
+ width?: number
55
+ height?: number
56
+ }
57
+
58
+ export type ImageOptimizer = (
59
+ src: string,
60
+ hints: { width?: number; height?: number },
61
+ ) => Promise<OptimizedImage | null>
62
+
63
+ // Quoted attribute values may hold a ">" (alt="a > b"), so a tag ends at the
64
+ // first ">" outside quotes. An unquoted value may hold a quote (alt=don't),
65
+ // which a browser reads as part of the value; there the quote-aware match
66
+ // would run on to the next quote and swallow the markup after the tag. A
67
+ // match that reaches a "<" is that case, and the tag is read up to its first
68
+ // ">" instead.
69
+ const IMG_START = /<img\\b/gi
70
+ const IMG_QUOTED = /<img\\b(?:[^>"']|"[^"]*"|'[^']*')*>/y
71
+ const IMG_PLAIN = /<img\\b[^>]*>/y
72
+ // An unquoted value runs to whitespace or ">", quotes included, as a browser reads it.
73
+ const ATTR = /([^\\s=/>]+)(?:\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+)))?/g
74
+
75
+ /** Every <img> tag in the html, with where it starts. */
76
+ function imgTags(html: string): Array<{ start: number; tag: string }> {
77
+ const found: Array<{ start: number; tag: string }> = []
78
+ for (const open of html.matchAll(IMG_START)) {
79
+ const start = open.index ?? 0
80
+ if (found.length && start < found[found.length - 1]!.start + found[found.length - 1]!.tag.length) continue
81
+ IMG_QUOTED.lastIndex = start
82
+ let tag = IMG_QUOTED.exec(html)?.[0]
83
+ if (tag === undefined || tag.includes('<', 1)) {
84
+ IMG_PLAIN.lastIndex = start
85
+ tag = IMG_PLAIN.exec(html)?.[0]
86
+ }
87
+ if (tag !== undefined) found.push({ start, tag })
88
+ }
89
+ return found
90
+ }
91
+ const SKIP_EXT = /\\.(svg|gif)(?:$|[?#])/i
92
+
93
+ // Attribute values are kept as the source wrote them (entities and all) and
94
+ // written back unchanged; only values this pass sets are escaped. Reading a
95
+ // value decodes it: a WordPress src like "photo.jpg?w=800&amp;ssl=1" is the
96
+ // URL "photo.jpg?w=800&ssl=1".
97
+ function parseAttrs(tag: string): Array<[string, string | null]> {
98
+ const inner = tag.replace(/^<img\\b/i, '').replace(/\\/?>$/, '')
99
+ const attrs: Array<[string, string | null]> = []
100
+ for (const m of inner.matchAll(ATTR)) {
101
+ const name = m[1]
102
+ if (!name) continue
103
+ // Written back double-quoted, so a double quote inside another form is escaped.
104
+ const value = m[2] ?? (m[3] ?? m[4])?.replace(/"/g, '&quot;')
105
+ attrs.push([name, value === undefined ? null : value])
106
+ }
107
+ return attrs
108
+ }
109
+
110
+ const NAMED: Record<string, string> = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' }
111
+
112
+ function decodeAttr(value: string): string {
113
+ return value.replace(/&(?:#(\\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (entity, dec, hex, name) => {
114
+ if (dec || hex) {
115
+ const code = dec ? Number(dec) : parseInt(hex, 16)
116
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity
117
+ }
118
+ return NAMED[name.toLowerCase()] ?? entity
119
+ })
120
+ }
121
+
122
+ function escapeAttr(value: string): string {
123
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
124
+ }
125
+
126
+ function serialize(attrs: Array<[string, string | null]>): string {
127
+ return \`<img\${attrs.map(([n, v]) => (v === null ? \` \${n}\` : \` \${n}="\${v}"\`)).join('')}>\`
128
+ }
129
+
130
+ function positiveInt(value: string | null | undefined): number | undefined {
131
+ if (!value) return undefined
132
+ const n = Number(value)
133
+ return Number.isInteger(n) && n > 0 ? n : undefined
134
+ }
135
+
136
+ /**
137
+ * Rewrite every <img> in the html. The first image is eager with high fetch
138
+ * priority; the rest are lazy. Images the optimizer accepts get its src,
139
+ * srcset and size. An existing loading or decoding attribute is respected.
140
+ */
141
+ export async function rewriteImages(
142
+ html: string,
143
+ optimize: ImageOptimizer,
144
+ options: { sizes: string },
145
+ ): Promise<string> {
146
+ const found = imgTags(html)
147
+ if (!found.length) return html
148
+ const replacements: string[] = []
149
+ for (let index = 0; index < found.length; index++) {
150
+ const tag = found[index]!.tag
151
+ const attrs = parseAttrs(tag)
152
+ const get = (name: string) => {
153
+ const raw = attrs.find(([n]) => n.toLowerCase() === name)?.[1]
154
+ return raw == null ? raw : decodeAttr(raw)
155
+ }
156
+ const has = (name: string) => attrs.some(([n]) => n.toLowerCase() === name)
157
+ const set = (name: string, value: string) => {
158
+ const i = attrs.findIndex(([n]) => n.toLowerCase() === name)
159
+ if (i >= 0) attrs[i] = [attrs[i]![0], escapeAttr(value)]
160
+ else attrs.push([name, escapeAttr(value)])
161
+ }
162
+ const remove = (name: string) => {
163
+ const i = attrs.findIndex(([n]) => n.toLowerCase() === name)
164
+ if (i >= 0) attrs.splice(i, 1)
165
+ }
166
+
167
+ if (!has('loading')) {
168
+ set('loading', index === 0 ? 'eager' : 'lazy')
169
+ if (index === 0 && !has('fetchpriority')) set('fetchpriority', 'high')
170
+ }
171
+ if (!has('decoding')) set('decoding', 'async')
172
+
173
+ const src = get('src')
174
+ const keep = has('data-cr-keep') || !src || src.startsWith('data:') || SKIP_EXT.test(src)
175
+ if (!keep) {
176
+ try {
177
+ const result = await optimize(src, { width: positiveInt(get('width')), height: positiveInt(get('height')) })
178
+ if (result) {
179
+ set('src', result.src)
180
+ if (result.srcset) {
181
+ set('srcset', result.srcset)
182
+ if (!has('sizes')) set('sizes', options.sizes)
183
+ } else {
184
+ // The source's own srcset (WordPress's size variants) would win
185
+ // over the optimized src in every browser that reads srcset.
186
+ remove('srcset')
187
+ remove('sizes')
188
+ }
189
+ if (result.width && result.height && !has('width') && !has('height')) {
190
+ set('width', String(result.width))
191
+ set('height', String(result.height))
192
+ }
193
+ }
194
+ }
195
+ catch {
196
+ // Keep the original image: a failed optimization must not cost the page its picture.
197
+ }
198
+ }
199
+ replacements.push(serialize(attrs))
200
+ }
201
+ let out = ''
202
+ let at = 0
203
+ found.forEach(({ start, tag }, i) => {
204
+ out += html.slice(at, start) + replacements[i]
205
+ at = start + tag.length
206
+ })
207
+ return out + html.slice(at)
208
+ }
209
+ `;
210
+ function optimizeImagesTs(patterns, widths, sizes) {
211
+ return `// Emitted by @contentrain/emitter-astro — binds the image pass to astro:assets.
212
+ // Only hosts on this list are optimized; Astro enforces the same list
213
+ // (image.remotePatterns in astro.config.mjs). Everything else keeps its URL
214
+ // and still gets lazy loading and async decoding (see images.ts).
215
+ import { getImage } from 'astro:assets'
216
+ import { rewriteImages } from './images'
217
+
218
+ const PATTERNS: Array<{ protocol: string; hostname: string; pathname?: string }> = ${JSON.stringify(patterns.map((p) => ({
219
+ protocol: p.protocol ?? "https",
220
+ hostname: p.hostname,
221
+ ...p.pathname ? { pathname: p.pathname } : {}
222
+ })))}
223
+ const WIDTHS = ${JSON.stringify(widths.toSorted((a, b) => a - b))}
224
+ const SIZES = ${JSON.stringify(sizes)}
225
+
226
+ // Astro downloads a remote image when the build writes its assets — after
227
+ // this pass, outside its error handling — and one image that does not answer
228
+ // 200 fails the whole astro build. So an image is only handed to getImage()
229
+ // once it answered with an image here; anything else keeps its original URL.
230
+ // One check per URL per build.
231
+ const CHECK_TIMEOUT_MS = 10_000
232
+ const checked = new Map<string, Promise<boolean>>()
233
+
234
+ async function answersWithImage(src: string, method: 'HEAD' | 'GET'): Promise<boolean | null> {
235
+ try {
236
+ const res = await fetch(src, { method, redirect: 'follow', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) })
237
+ if (method === 'GET') await res.body?.cancel()
238
+ // Some hosts refuse HEAD; ask again with GET.
239
+ if (method === 'HEAD' && (res.status === 405 || res.status === 501 || res.status === 403)) return null
240
+ return res.ok && (res.headers.get('content-type') ?? '').toLowerCase().startsWith('image/')
241
+ }
242
+ catch {
243
+ return method === 'HEAD' ? null : false
244
+ }
245
+ }
246
+
247
+ function reachable(src: string): Promise<boolean> {
248
+ let pending = checked.get(src)
249
+ if (!pending) {
250
+ pending = answersWithImage(src, 'HEAD').then((ok) => ok ?? answersWithImage(src, 'GET').then((retry) => retry === true))
251
+ checked.set(src, pending)
252
+ }
253
+ return pending
254
+ }
255
+
256
+ function allowed(src: string): boolean {
257
+ let url: URL
258
+ try {
259
+ url = new URL(src)
260
+ }
261
+ catch {
262
+ return false
263
+ }
264
+ return PATTERNS.some((p) =>
265
+ url.protocol === \`\${p.protocol}:\`
266
+ && (p.hostname.startsWith('*.') ? url.hostname.endsWith(p.hostname.slice(1)) : url.hostname === p.hostname)
267
+ && (!p.pathname || url.pathname.startsWith(p.pathname)))
268
+ }
269
+
270
+ export async function optimizeHtmlImages(html: string): Promise<string> {
271
+ if (!html.includes('<img')) return html
272
+ return rewriteImages(html, async (src, hints) => {
273
+ if (!allowed(src) || !(await reachable(src))) return null
274
+ const sized = hints.width !== undefined && hints.height !== undefined
275
+ const widths = WIDTHS.filter((w) => hints.width === undefined || w <= hints.width)
276
+ const image = await getImage({
277
+ src,
278
+ format: 'webp',
279
+ ...(sized ? { width: hints.width, height: hints.height } : { inferSize: true }),
280
+ ...(widths.length ? { widths } : {}),
281
+ })
282
+ const width = Number(image.attributes.width)
283
+ const height = Number(image.attributes.height)
284
+ return {
285
+ src: image.src,
286
+ srcset: image.srcSet.attribute || undefined,
287
+ ...(width > 0 && height > 0 ? { width, height } : {}),
288
+ }
289
+ }, { sizes: SIZES })
290
+ }
291
+ `;
292
+ }
293
+ //#endregion
6
294
  //#region src/util.ts
7
295
  /** "f-archive" → "FArchive", "posts" → "Posts" — Astro component file names. */
8
296
  function pascalCase(id) {
@@ -43,11 +331,376 @@ function stableJson(value) {
43
331
  return `${JSON.stringify(sortDeep(value), null, 2)}\n`;
44
332
  }
45
333
  //#endregion
334
+ //#region src/pages.ts
335
+ function routeFiles(route, family, content, siteLocale, seo = true, archiveFeed = false) {
336
+ const warnings = [];
337
+ const files = {};
338
+ const pagePath = patternToPagePath(route.pattern);
339
+ if (!pagePath) return {
340
+ files,
341
+ warnings: [`route ${route.id}: pattern "${route.pattern}" cannot be expressed as an Astro page — skipped`]
342
+ };
343
+ if (!family) return {
344
+ files,
345
+ warnings: [`route ${route.id}: family "${route.family}" not found — skipped`]
346
+ };
347
+ const layout = pascalCase(family.id);
348
+ const depth = pagePath.split("/").length - 1;
349
+ const up = "../".repeat(depth + 1) || "../";
350
+ const hasParams = pagePath.includes("[");
351
+ const restAt = restParamIndex(pagePath);
352
+ if (restAt !== -1 && restAt !== pagePath.split("/").length - 1) warnings.push(`route ${route.id}: rest parameter is not the final segment of "${route.pattern}" — Astro matches rest parameters greedily; verify this route resolves`);
353
+ if (route.kind === "single" || route.collection !== void 0) {
354
+ const collection = route.collection ?? "posts";
355
+ const posts = collectionItems(content, collection);
356
+ if (!posts.length) warnings.push(`route ${route.id}: collection "${collection}" has no items — page emitted, data file empty`);
357
+ files[`src/data/${collection}.json`] = stableJson(posts);
358
+ files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale, hasParams, seo);
359
+ return {
360
+ files,
361
+ warnings
362
+ };
363
+ }
364
+ const queryPages = route.query ? content.queries?.[route.query] ?? [] : [];
365
+ if (route.query) {
366
+ if (!queryPages.length) warnings.push(`route ${route.id}: query "${route.query}" has no data — page emitted, data file empty`);
367
+ if (queryPages.some((p) => !p.item_template && !p.sections?.length)) warnings.push(`route ${route.id}: item template missing on some pages — plain fallback list rendered (not fidelity)`);
368
+ files[`src/data/queries/${route.query}.json`] = stableJson(queryPages);
369
+ files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale, seo, archiveFeed);
370
+ return {
371
+ files,
372
+ warnings
373
+ };
374
+ }
375
+ files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale, seo);
376
+ return {
377
+ files,
378
+ warnings
379
+ };
380
+ }
381
+ /** Items of a named collection; `posts` also accepts the `content.posts` shorthand. */
382
+ function collectionItems(content, collection) {
383
+ const named = content.collections?.[collection];
384
+ if (named) return named;
385
+ return collection === "posts" ? content.posts ?? [] : [];
386
+ }
387
+ function singlePage(route, layout, up, collection, siteLocale, hasParams, seo) {
388
+ const locale = JSON.stringify(route.locale ?? siteLocale);
389
+ return `---
390
+ // Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro
391
+ import Layout from '${up}layouts/${layout}.astro'
392
+ import data from '${up}data/${collection}.json'
393
+ import { postMarks${seo ? ", postSeo" : ""}, type EmittedPost } from '${up}lib/fill'
394
+
395
+ ${hasParams ? `export function getStaticPaths() {
396
+ // The JSON is data; its contract is EmittedPost. Inferring the type from the
397
+ // file would type only the fields this site happens to carry, and "astro
398
+ // check" (which the build runs first) would then reject post.params on a site
399
+ // whose permalinks need none. The cast lives INSIDE getStaticPaths because
400
+ // Astro hoists this function: it sees imports, not the component scope.
401
+ const posts = data as EmittedPost[]
402
+ // A post carries its own route parameters (date parts of a dated permalink,
403
+ // a post id) — using the template post's would send every other post to the
404
+ // wrong address.
405
+ return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))
406
+ }
407
+ type Props = { post: EmittedPost }
408
+ const { post } = Astro.props as Props` : `const posts = data as EmittedPost[]
409
+ if (posts.length !== 1) throw new Error(${JSON.stringify(`Static content route requires exactly one entry: ${route.id}`)})
410
+ const post = posts[0]!`}
411
+ ---
412
+ <Layout
413
+ title={post.title}
414
+ marks={postMarks(post)}
415
+ body={post.body}
416
+ css={post.css ?? []}
417
+ lang={post.locale ?? ${locale}}
418
+ entry={post.entry}${seo ? `
419
+ seo={postSeo(post${route.kind === "page" ? ", 'page'" : ""})}` : ""}
420
+ />
421
+ `;
422
+ }
423
+ function listPage(route, layout, up, hasParams, siteLocale, seo, archiveFeed = false) {
424
+ const locale = JSON.stringify(route.locale ?? siteLocale);
425
+ const routeTitle = route.title === void 0 ? `''` : JSON.stringify(route.title);
426
+ const paths = hasParams ? `export function getStaticPaths() {
427
+ const pages = data as EmittedQueryPage[]
428
+ return pages.map((page) => ({ params: page.params, props: { page } }))
429
+ }
430
+ type Props = { page: EmittedQueryPage }
431
+ const { page } = Astro.props as Props` : `const page = (data as EmittedQueryPage[])[0] ?? { params: {}, items: [], title: undefined }`;
432
+ return `---
433
+ // Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
434
+ import Layout from '${up}layouts/${layout}.astro'
435
+ import data from '${up}data/queries/${route.query}.json'
436
+ import { ${archiveFeed && seo ? "entryAddress, " : ""}renderQueryPage, type EmittedQueryPage } from '${up}lib/fill'
437
+
438
+ ${paths}
439
+ // A list renders in sections (a big card then a grid) — one template is the
440
+ // single-section case. Without a template at all it falls back to a plain list
441
+ // and the emitter has already warned: silence would read as fidelity.
442
+ const content = renderQueryPage(page)
443
+ // Route params carry slugs; page marks carry what the chrome needs to SHOW
444
+ // (a term's display name, description, count).
445
+ const marks = { ...page.params, ...(page.marks ?? {}) }
446
+ const title = page.title ?? ${routeTitle}
447
+ ---
448
+ <Layout
449
+ title={title || String(marks.term_name ?? page.params.term ?? '')}
450
+ marks={marks}
451
+ body={content}
452
+ css={page.css ?? []}
453
+ lang={${locale}}${seo ? `
454
+ seo={{ description: page.description, image: page.image, imageMeta: page.image_meta, canonical: page.canonical, breadcrumbs: page.breadcrumbs, noindex: page.noindex, nofollow: page.nofollow, openGraph: page.open_graph, twitter: page.twitter, schema: page.schema, profile: page.profile,${archiveFeed ? ` feed: { path: (entryAddress(${JSON.stringify(route.pattern)}, page.params) ?? '/') + 'feed.xml', title: title || undefined },` : ""} type: 'website', pageType: 'CollectionPage' }}` : ""}
455
+ />
456
+ `;
457
+ }
458
+ function staticPage(route, layout, up, hasParams, warnings, siteLocale, seo) {
459
+ if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`);
460
+ const paths = hasParams ? `export function getStaticPaths() {
461
+ return []
462
+ }
463
+ ` : "";
464
+ return `---
465
+ // Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
466
+ import Layout from '${up}layouts/${layout}.astro'
467
+ ${paths}---
468
+ <Layout title=${JSON.stringify(route.title ?? "")} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}}${seo ? ` seo={{ type: 'website' }}` : ""} />
469
+ `;
470
+ }
471
+ /** Share a catchall while preserving the original editable collections. */
472
+ function sharedCollectionPage(routes, content, siteLocale, seo = true) {
473
+ const pattern = routes[0]?.pattern;
474
+ if (!pattern || routes.length < 2 || !/^\/(?:[^:*]+\/)*:slug\*$/.test(pattern) || routes.some((r) => r.pattern !== pattern || r.query || r.kind !== "single" && !r.collection)) return void 0;
475
+ const seen = /* @__PURE__ */ new Set();
476
+ for (const route of routes) for (const post of collectionItems(content, route.collection ?? "posts")) {
477
+ let key;
478
+ try {
479
+ key = decodeURIComponent(post.slug).replace(/\/+$/, "");
480
+ } catch {
481
+ return;
482
+ }
483
+ if (seen.has(key)) return void 0;
484
+ seen.add(key);
485
+ }
486
+ const up = "../".repeat(patternToPagePath(pattern).split("/").length);
487
+ return `---
488
+ // Shared collection routes: ${routes.map((r) => r.id).join(", ")}
489
+ ${routes.map((r, i) => `import Layout${i} from '${up}layouts/${pascalCase(r.family)}.astro'\nimport data${i} from '${up}data/${r.collection ?? "posts"}.json'`).join("\n")}
490
+ import { postMarks${seo ? ", postSeo" : ""}, type EmittedPost } from '${up}lib/fill'
491
+ export function getStaticPaths() {
492
+ const sources = [${routes.map((_, i) => `data${i}`).join(", ")}] as EmittedPost[][]
493
+ const seen = new Set<string>()
494
+ return sources.flatMap((posts, routeIndex) => posts.map(post => {
495
+ const key = decodeURIComponent(post.slug).replace(/\\/+$/, '')
496
+ if (seen.has(key)) throw new Error('Route collision in shared collections: ' + post.slug)
497
+ seen.add(key)
498
+ return { params: { ...(post.params ?? {}), slug: post.slug }, props: { post, routeIndex } }
499
+ }))
500
+ }
501
+ const { post, routeIndex } = Astro.props as { post: EmittedPost, routeIndex: number }
502
+ const layouts = [${routes.map((_, i) => `Layout${i}`).join(", ")}]
503
+ const locales = ${JSON.stringify(routes.map((r) => r.locale ?? siteLocale))}
504
+ const kinds = ${JSON.stringify(routes.map((r) => r.kind === "page" ? "page" : "post"))} as Array<'post' | 'page'>
505
+ const Layout = layouts[routeIndex]!
506
+ ---
507
+ <Layout title={post.title} marks={postMarks(post)} body={post.body}
508
+ css={post.css ?? []} lang={post.locale ?? locales[routeIndex]} entry={post.entry}${seo ? " seo={postSeo(post, kinds[routeIndex])}" : ""} />
509
+ `;
510
+ }
511
+ //#endregion
512
+ //#region src/alternates.ts
513
+ /**
514
+ * The path Astro builds for a route pattern and a set of parameters, in the
515
+ * directory format the scaffold configures (`/en/hello/`). Null when a
516
+ * parameter has no value — that page has no address to name.
517
+ */
518
+ function entryPath(pattern, params) {
519
+ const segments = pattern.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
520
+ if (!segments.length) return "/";
521
+ const out = [];
522
+ for (const segment of segments) {
523
+ if (!segment.startsWith(":")) {
524
+ out.push(segment);
525
+ continue;
526
+ }
527
+ const value = params[segment.slice(1).replace(/\*$/, "")]?.replace(/^\/+|\/+$/g, "");
528
+ if (!value) return null;
529
+ out.push(value);
530
+ }
531
+ return `/${out.join("/")}/`;
532
+ }
533
+ const byLangTag = (a, b) => a.lang < b.lang ? -1 : a.lang > b.lang ? 1 : 0;
534
+ /**
535
+ * Attach `alternates` to every entry that has translations on the site.
536
+ *
537
+ * The language of a page is the one its `<html lang>` gets: the post's own
538
+ * locale, else its route's, else the site's. A site with one language has no
539
+ * alternates and nothing is attached.
540
+ */
541
+ function withAlternates(routes, content, siteLocale) {
542
+ const warnings = [];
543
+ const placements = /* @__PURE__ */ new Map();
544
+ for (const route of routes) {
545
+ if (route.kind !== "single" && route.collection === void 0) continue;
546
+ const pagePath = patternToPagePath(route.pattern);
547
+ if (!pagePath) continue;
548
+ const collection = route.collection ?? "posts";
549
+ const posts = collectionItems(content, collection);
550
+ if (!pagePath.includes("[") && posts.length !== 1) continue;
551
+ for (const post of posts) {
552
+ const list = placements.get(post) ?? [];
553
+ list.push({
554
+ lang: post.locale ?? route.locale ?? siteLocale,
555
+ path: entryPath(route.pattern, {
556
+ ...post.params,
557
+ slug: post.slug
558
+ }),
559
+ key: post.entry ? JSON.stringify([post.entry.model_id, post.entry.entry_id]) : void 0,
560
+ collection
561
+ });
562
+ placements.set(post, list);
563
+ }
564
+ }
565
+ if (new Set([...placements.values()].flat().map((p) => p.lang)).size < 2) return {
566
+ content,
567
+ warnings
568
+ };
569
+ const groups = /* @__PURE__ */ new Map();
570
+ const unkeyed = /* @__PURE__ */ new Map();
571
+ const ambiguous = /* @__PURE__ */ new Map();
572
+ for (const [post, list] of placements) {
573
+ const first = list[0];
574
+ if (list.length > 1 || first.path === null) {
575
+ ambiguous.set(first.collection, (ambiguous.get(first.collection) ?? 0) + 1);
576
+ continue;
577
+ }
578
+ if (!first.key) {
579
+ unkeyed.set(first.collection, (unkeyed.get(first.collection) ?? 0) + 1);
580
+ continue;
581
+ }
582
+ const group = groups.get(first.key) ?? [];
583
+ group.push({
584
+ post,
585
+ lang: first.lang,
586
+ path: first.path
587
+ });
588
+ groups.set(first.key, group);
589
+ }
590
+ for (const [collection, count] of unkeyed) warnings.push(`collection ${collection}: ${count} posts carry no entry address — hreflang cannot link them to their translations`);
591
+ for (const [collection, count] of ambiguous) warnings.push(`collection ${collection}: ${count} posts have no single address (served by more than one route, or a route parameter without a value) — no hreflang on those pages`);
592
+ const alternatesOf = /* @__PURE__ */ new Map();
593
+ for (const group of groups.values()) {
594
+ if (group.length < 2) continue;
595
+ const byLang = /* @__PURE__ */ new Map();
596
+ let conflict = false;
597
+ for (const member of group) {
598
+ const seen = byLang.get(member.lang);
599
+ if (seen !== void 0 && seen !== member.path) conflict = true;
600
+ byLang.set(member.lang, member.path);
601
+ }
602
+ const entry = group[0].post.entry;
603
+ if (conflict) {
604
+ warnings.push(`entry ${entry.model_id}/${entry.entry_id}: two pages claim the same language — no hreflang for this entry`);
605
+ continue;
606
+ }
607
+ if (byLang.size < 2) continue;
608
+ const alternates = [...byLang].map(([lang, path]) => ({
609
+ lang,
610
+ path
611
+ })).toSorted(byLangTag);
612
+ const fallback = byLang.get(siteLocale);
613
+ if (fallback !== void 0) alternates.push({
614
+ lang: "x-default",
615
+ path: fallback
616
+ });
617
+ for (const member of group) alternatesOf.set(member.post, alternates);
618
+ }
619
+ if (!alternatesOf.size) return {
620
+ content,
621
+ warnings
622
+ };
623
+ const mark = (posts) => posts.map((post) => {
624
+ const alternates = alternatesOf.get(post);
625
+ return alternates ? {
626
+ ...post,
627
+ alternates
628
+ } : post;
629
+ });
630
+ return {
631
+ content: {
632
+ ...content,
633
+ ...content.posts ? { posts: mark(content.posts) } : {},
634
+ ...content.collections ? { collections: Object.fromEntries(Object.entries(content.collections).map(([name, posts]) => [name, mark(posts)])) } : {}
635
+ },
636
+ warnings
637
+ };
638
+ }
639
+ //#endregion
640
+ //#region src/noindex.ts
641
+ /** Site-root paths (directory format, `/a/b/`) of every noindex page, sorted. */
642
+ function noindexPaths(routes, content) {
643
+ const out = /* @__PURE__ */ new Set();
644
+ for (const route of routes) {
645
+ const pagePath = patternToPagePath(route.pattern);
646
+ if (!pagePath) continue;
647
+ const dynamic = pagePath.includes("[");
648
+ if (route.kind === "single" || route.collection !== void 0) {
649
+ const posts = collectionItems(content, route.collection ?? "posts");
650
+ if (!dynamic && posts.length !== 1) continue;
651
+ for (const post of posts) {
652
+ if (!post.noindex) continue;
653
+ const path = entryPath(route.pattern, {
654
+ ...post.params,
655
+ slug: post.slug
656
+ });
657
+ if (path) out.add(path);
658
+ }
659
+ }
660
+ if (route.query) for (const page of content.queries?.[route.query] ?? []) {
661
+ if (!page.noindex) continue;
662
+ const path = entryPath(route.pattern, page.params);
663
+ if (path) out.add(path);
664
+ }
665
+ }
666
+ return [...out].toSorted();
667
+ }
668
+ /**
669
+ * The `sitemap()` call for astro.config.mjs. The sitemap hands `filter` an
670
+ * absolute, percent-encoded URL; it is compared by decoded path with the
671
+ * trailing slash normalised, the form `noindexPaths` returns.
672
+ */
673
+ function sitemapIntegration(paths) {
674
+ if (!paths.length) return "sitemap()";
675
+ return `sitemap({ filter: (page) => !NOINDEX.has(sitemapPath(page)) })`;
676
+ }
677
+ /** The declarations `sitemapIntegration` refers to, placed above `defineConfig`. */
678
+ function sitemapFilterDeclarations(paths) {
679
+ if (!paths.length) return [];
680
+ return [
681
+ `// Pages the source site kept out of search (noindex) stay out of the sitemap.`,
682
+ `const NOINDEX = new Set(${JSON.stringify(paths)})`,
683
+ `const sitemapPath = (page) => {`,
684
+ ` let path = new URL(page).pathname`,
685
+ ` try { path = decodeURI(path) } catch { /* keep the encoded path */ }`,
686
+ ` return path.endsWith('/') ? path : \`\${path}/\``,
687
+ `}`,
688
+ ``
689
+ ];
690
+ }
691
+ //#endregion
46
692
  //#region src/scaffold.ts
47
- function scaffoldFiles(ir, options) {
693
+ /**
694
+ * `noindex` holds the site-root paths the sitemap leaves out (see noindex.ts);
695
+ * `redirectsConfig` is the `redirects` block for astro.config.mjs (see redirects.ts);
696
+ * `runtime` adds its host to the image allow-list (see images.ts).
697
+ */
698
+ function scaffoldFiles(ir, options, noindex = [], redirectsConfig = null, runtime) {
48
699
  const tailwind = options.tailwind !== false;
49
700
  const split = ir.viewport_strategy === "split";
50
701
  const sitemap = options.sitemap !== false && Boolean(ir.site.url);
702
+ const images = imagesEnabled(options);
703
+ const imageConfig = images ? astroImageConfig(imagePatterns(options, runtime)) : null;
51
704
  const files = {};
52
705
  files["package.json"] = stableJson({
53
706
  name: options.projectName ?? "migrated-site",
@@ -68,7 +721,8 @@ function scaffoldFiles(ir, options) {
68
721
  tailwindcss: "^4.0.0",
69
722
  "@tailwindcss/vite": "^4.0.0"
70
723
  } : {},
71
- ...sitemap ? { "@astrojs/sitemap": "^3.7.0" } : {}
724
+ ...sitemap ? { "@astrojs/sitemap": "^3.7.0" } : {},
725
+ ...images ? { sharp: "^0.34.0" } : {}
72
726
  },
73
727
  devDependencies: {
74
728
  "@astrojs/check": "^0.9.0",
@@ -80,10 +734,13 @@ function scaffoldFiles(ir, options) {
80
734
  ...tailwind ? [`import tailwindcss from '@tailwindcss/vite'`] : [],
81
735
  ...sitemap ? [`import sitemap from '@astrojs/sitemap'`] : [],
82
736
  ``,
737
+ ...sitemap ? sitemapFilterDeclarations(noindex) : [],
83
738
  `export default defineConfig({`,
84
739
  ...ir.site.url ? [` site: ${JSON.stringify(ir.site.url)},`] : [],
85
- ` build: { format: 'directory' },`,
86
- ...sitemap ? [` integrations: [sitemap()],`] : [],
740
+ ...options.trailingSlash === false ? [` build: { format: 'file' },`, ` trailingSlash: 'never',`] : [` build: { format: 'directory' },`],
741
+ ...redirectsConfig ? [redirectsConfig] : [],
742
+ ...sitemap ? [` integrations: [${sitemapIntegration(noindex)}],`] : [],
743
+ ...imageConfig ? [imageConfig] : [],
87
744
  ...tailwind ? [` vite: { plugins: [tailwindcss()] },`] : [],
88
745
  `})`,
89
746
  ``
@@ -100,6 +757,10 @@ function scaffoldFiles(ir, options) {
100
757
  if (tailwind) files["src/styles/modern.css"] = modernCss(ir.tokens);
101
758
  if (options.sitemap !== false) files["public/robots.txt"] = robotsTxt(ir.site.url);
102
759
  files["src/lib/fill.ts"] = FILL_TS;
760
+ if (images) {
761
+ files["src/lib/images.ts"] = IMAGES_TS;
762
+ files["src/lib/optimize-images.ts"] = optimizeImagesTs(imagePatterns(options, runtime), options.images?.widths ?? DEFAULT_IMAGE_WIDTHS, options.images?.sizes ?? "(max-width: 768px) 100vw, 768px");
763
+ }
103
764
  return files;
104
765
  }
105
766
  /**
@@ -416,7 +1077,10 @@ export function validTrail(trail: unknown): Breadcrumb[] | undefined {
416
1077
  export interface PageStructuredDataInput {
417
1078
  url?: string
418
1079
  site?: URL
1080
+ /** The document title: the page node's name. */
419
1081
  title: string
1082
+ /** The entry's own title, when \`title\` is one an SEO plugin composed: Article headline, last crumb. */
1083
+ headline?: string
420
1084
  description?: string
421
1085
  image?: string
422
1086
  locale?: string
@@ -425,12 +1089,77 @@ export interface PageStructuredDataInput {
425
1089
  publishedAt?: string
426
1090
  modifiedAt?: string
427
1091
  author?: string
1092
+ /** The author's page (their archive), absolute: the Article author's \`url\`. */
1093
+ authorUrl?: string
1094
+ /** On an author's archive: who the page is about — it becomes a ProfilePage whose main entity is this Person. */
1095
+ profile?: AuthorProfile
1096
+ /** false: the site's addresses end without a slash — an author's page is named in that form. */
1097
+ trailingSlash?: boolean
428
1098
  siteName?: string
429
1099
  /** \`@id\` of the site's own WebSite node, when its head declares one. */
430
1100
  websiteId?: string
1101
+ /** \`@id\` of the node the kept head says publishes the site (Organization or Person). */
1102
+ publisherId?: string
1103
+ /** What \`image\` is — an Article image with its size is an ImageObject. */
1104
+ imageMeta?: ImageMeta
431
1105
  breadcrumbs?: Breadcrumb[]
432
1106
  }
433
1107
 
1108
+ /**
1109
+ * Open Graph's locale form, \`ll_RR\` (\`tr_TR\`), from a BCP 47 tag
1110
+ * (\`tr-TR\`). A tag without a region has no OG form — guessing one would
1111
+ * name the wrong country — so it gives undefined and the tag is left out.
1112
+ */
1113
+ export function ogLocale(tag: string | undefined): string | undefined {
1114
+ const match = /^([a-z]{2,3})[-_]([a-z]{2})$/i.exec(tag ?? '')
1115
+ return match ? match[1]!.toLowerCase() + '_' + match[2]!.toUpperCase() : undefined
1116
+ }
1117
+
1118
+ /** An author as their archive page presents them: the Person a ProfilePage is about. */
1119
+ export interface AuthorProfile {
1120
+ name: string
1121
+ /** The author's bio. */
1122
+ description?: string
1123
+ /** Avatar or photo, absolute or site-root-relative. */
1124
+ image?: string
1125
+ /** The author's profiles elsewhere (social accounts, a personal site) — schema.org \`sameAs\`. */
1126
+ same_as?: string[]
1127
+ }
1128
+
1129
+ /**
1130
+ * An address on this site in the form its pages are named in — absolute, the
1131
+ * page path in the site's trailing-slash form, no query or fragment — so a
1132
+ * reference to a page and the page itself name one URL. Another host's
1133
+ * address is kept as given.
1134
+ */
1135
+ function siteAddress(value: string | undefined, site: URL | undefined, trailingSlash = true): string | undefined {
1136
+ const absolute = absoluteUrl(value, site)
1137
+ if (!absolute || !site) return absolute
1138
+ const url = new URL(absolute)
1139
+ if (url.host.toLowerCase() !== site.host.toLowerCase()) return absolute
1140
+ return absoluteUrl(pagePath(url.pathname, trailingSlash), site)
1141
+ }
1142
+
1143
+ /** An Article image: an ImageObject when its size is known, the bare URL otherwise. */
1144
+ function articleImage(url: string, meta: ImageMeta | undefined): string | Record<string, unknown> {
1145
+ const tags = imageMetaTags(meta)
1146
+ if (!tags.width || !tags.height) return url
1147
+ return { '@type': 'ImageObject', url, width: Number(tags.width), height: Number(tags.height) }
1148
+ }
1149
+
1150
+ /**
1151
+ * Who publishes the article: the node the site's own head declares, by
1152
+ * \`@id\`, so the logo and profile it carries count; else the site by name
1153
+ * and address. Nothing is invented beyond the name the site gives itself.
1154
+ */
1155
+ function publisher(input: PageStructuredDataInput): Record<string, unknown> | undefined {
1156
+ if (input.publisherId) return { '@id': input.publisherId }
1157
+ if (!input.siteName) return undefined
1158
+ // The site's own address, path included: a site under /blog/ is not its host.
1159
+ const home = input.site ? new URL(input.site.pathname.endsWith('/') ? input.site.href : input.site.href + '/').toString() : undefined
1160
+ return { '@type': 'Organization', name: input.siteName, ...(home ? { url: home } : {}) }
1161
+ }
1162
+
434
1163
  /**
435
1164
  * The page's structured data as one graph: the page itself (WebPage, or
436
1165
  * CollectionPage for a list), its breadcrumb trail, and on an entry the
@@ -449,9 +1178,23 @@ export function pageStructuredData(input: PageStructuredDataInput): Record<strin
449
1178
  ? trail.map((c) => ({ name: c.name, item: absoluteUrl(c.path, input.site) }))
450
1179
  : []
451
1180
  const breadcrumbId = url && trail && crumbs.every((c) => c.item) ? url + '#breadcrumb' : undefined
1181
+ const authorUrl = siteAddress(input.authorUrl, input.site, input.trailingSlash)
1182
+ // An author's archive is about a person: the page is a ProfilePage and the
1183
+ // Person its main entity, at the page's own address — the one the author's
1184
+ // articles name as their author's url.
1185
+ const sameAs = (input.profile?.same_as ?? []).filter((u) => /^https?:\\/\\//i.test(u))
1186
+ const profile = url && input.profile?.name ? {
1187
+ '@type': 'Person',
1188
+ '@id': url + '#person',
1189
+ name: input.profile.name,
1190
+ url,
1191
+ ...(input.profile.description ? { description: input.profile.description } : {}),
1192
+ ...(absoluteUrl(input.profile.image, input.site) ? { image: absoluteUrl(input.profile.image, input.site) } : {}),
1193
+ ...(sameAs.length ? { sameAs } : {}),
1194
+ } : undefined
452
1195
  if (url) {
453
1196
  graph.push({
454
- '@type': input.pageType ?? 'WebPage',
1197
+ '@type': profile ? 'ProfilePage' : input.pageType ?? 'WebPage',
455
1198
  '@id': url,
456
1199
  url,
457
1200
  ...(input.title ? { name: input.title } : {}),
@@ -459,13 +1202,18 @@ export function pageStructuredData(input: PageStructuredDataInput): Record<strin
459
1202
  ...(input.locale ? { inLanguage: input.locale } : {}),
460
1203
  ...(input.websiteId ? { isPartOf: { '@id': input.websiteId } } : {}),
461
1204
  ...(breadcrumbId ? { breadcrumb: { '@id': breadcrumbId } } : {}),
1205
+ ...(profile ? { mainEntity: { '@id': profile['@id'] } } : {}),
1206
+ // A page is dated as its entry is — WordPress pages have dates too.
1207
+ ...(input.publishedAt ? { datePublished: input.publishedAt } : {}),
1208
+ ...(input.modifiedAt ? { dateModified: input.modifiedAt } : {}),
462
1209
  })
463
1210
  }
1211
+ if (profile) graph.push(profile)
464
1212
  if (breadcrumbId) {
465
1213
  graph.push({
466
1214
  '@type': 'BreadcrumbList',
467
1215
  '@id': breadcrumbId,
468
- itemListElement: [...crumbs, { name: input.title, item: url }].map((c, i) => ({
1216
+ itemListElement: [...crumbs, { name: input.headline ?? input.title, item: url }].map((c, i) => ({
469
1217
  '@type': 'ListItem',
470
1218
  position: i + 1,
471
1219
  name: c.name,
@@ -476,19 +1224,227 @@ export function pageStructuredData(input: PageStructuredDataInput): Record<strin
476
1224
  if (input.article) {
477
1225
  graph.push({
478
1226
  '@type': 'Article',
479
- headline: input.title,
1227
+ headline: input.headline ?? input.title,
480
1228
  ...(input.description ? { description: input.description } : {}),
481
- ...(input.image ? { image: [input.image] } : {}),
1229
+ ...(input.image ? { image: [articleImage(input.image, input.imageMeta)] } : {}),
482
1230
  ...(input.publishedAt ? { datePublished: input.publishedAt } : {}),
483
1231
  ...(input.modifiedAt ? { dateModified: input.modifiedAt } : {}),
484
- ...(input.author ? { author: { '@type': 'Person', name: input.author } } : {}),
1232
+ // The author by the same @id their archive's ProfilePage gives its Person,
1233
+ // so the two are one entity; name and url stay for a reader of this page alone.
1234
+ ...(input.author ? { author: { '@type': 'Person', ...(authorUrl ? { '@id': authorUrl + '#person' } : {}), name: input.author, ...(authorUrl ? { url: authorUrl } : {}) } } : {}),
485
1235
  ...(url ? { mainEntityOfPage: { '@id': url } } : {}),
486
- ...(input.siteName ? { publisher: { '@type': 'Organization', name: input.siteName } } : {}),
1236
+ ...(publisher(input) ? { publisher: publisher(input) } : {}),
487
1237
  })
488
1238
  }
489
1239
  return graph.length ? { '@context': 'https://schema.org', '@graph': graph } : undefined
490
1240
  }
491
1241
 
1242
+ const SITE_SEARCH = 'SearchAction'
1243
+
1244
+ /**
1245
+ * An \`@id\` compared as an address: the scheme, the host's case and a slash
1246
+ * before the fragment are not part of it, so \`https://a.com/#website\` and
1247
+ * \`http://A.com#website\` name one node — the head and a page's graph can
1248
+ * each have been rewritten to the new origin in their own way.
1249
+ */
1250
+ const ldIdKey = (id: string): string => id.trim()
1251
+ .replace(/^(?:https?:)?\\/\\/[^/?#]*/i, (origin) => origin.toLowerCase().replace(/^https?:/, ''))
1252
+ .replace(/\\/+(?=#|$)/, '')
1253
+
1254
+ /**
1255
+ * The page's structured data as the source's SEO plugin rendered it, ready to
1256
+ * print — or undefined, and the generated graph is used. What the plugin
1257
+ * described is kept as it was (FAQ, HowTo, Product, its WebPage and Article)
1258
+ * except a WebSite's SearchAction, which would be false on the migrated site
1259
+ * (a static site has no ?s= search), and the nodes the kept head already
1260
+ * carries (\`headIds\` — WebSite, Organization, its logo), which every page
1261
+ * would otherwise print twice.
1262
+ */
1263
+ export function sourceStructuredData(schema: unknown, headIds: string[] = []): Record<string, unknown> | undefined {
1264
+ if (!schema || typeof schema !== 'object') return undefined
1265
+ const root = schema as Record<string, unknown>
1266
+ const graph = root['@graph']
1267
+ const nodes: unknown[] = Array.isArray(schema) ? schema : Array.isArray(graph) ? graph : [schema]
1268
+ const typesOf = (node: Record<string, unknown>): string[] =>
1269
+ (Array.isArray(node['@type']) ? node['@type'] : [node['@type']]).filter((t): t is string => typeof t === 'string')
1270
+ const inHead = new Set(headIds.map(ldIdKey))
1271
+ const kept: Array<Record<string, unknown>> = []
1272
+ for (const value of nodes) {
1273
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue
1274
+ let node = value as Record<string, unknown>
1275
+ if (!typesOf(node).length) continue
1276
+ const website = typesOf(node).some((t) => t.toLowerCase() === 'website')
1277
+ if (typeof node['@id'] === 'string' && inHead.has(ldIdKey(node['@id']))) continue
1278
+ if (website && node['potentialAction'] !== undefined) {
1279
+ const action = node['potentialAction']
1280
+ const actions = (Array.isArray(action) ? action : [action]).filter((a) =>
1281
+ !(a && typeof a === 'object' && typesOf(a as Record<string, unknown>).includes(SITE_SEARCH)))
1282
+ const { potentialAction: _, ...rest } = node
1283
+ node = actions.length ? { ...rest, potentialAction: Array.isArray(action) ? actions : actions[0] } : rest
1284
+ }
1285
+ kept.push(node)
1286
+ }
1287
+ if (!kept.length) return undefined
1288
+ const context = Array.isArray(schema) ? undefined : root['@context']
1289
+ return { '@context': context ?? 'https://schema.org', '@graph': kept }
1290
+ }
1291
+
1292
+ // ─── Feed and llms.txt ───
1293
+
1294
+ /**
1295
+ * Site-root address of an entry: its route pattern filled with the entry's
1296
+ * parameters — the values getStaticPaths builds the page from — in the
1297
+ * directory format the build writes. Undefined when a parameter is missing.
1298
+ */
1299
+ export function entryAddress(pattern: string, params: Record<string, string | undefined>, trailingSlash = true): string | undefined {
1300
+ const segments = pattern.replace(/^\\/+|\\/+$/g, '').split('/').filter(Boolean)
1301
+ if (!segments.length) return '/'
1302
+ const out: string[] = []
1303
+ for (const segment of segments) {
1304
+ if (!segment.startsWith(':')) {
1305
+ out.push(segment)
1306
+ continue
1307
+ }
1308
+ const value = params[segment.slice(1).replace(/\\*$/, '')]?.replace(/^\\/+|\\/+$/g, '')
1309
+ if (!value) return undefined
1310
+ out.push(value)
1311
+ }
1312
+ return '/' + out.join('/') + (trailingSlash ? '/' : '')
1313
+ }
1314
+
1315
+ /**
1316
+ * The address a built page is served at, from the path Astro gives it. The
1317
+ * file build names a page \`/hello.html\` and the host serves it at
1318
+ * \`/hello\`; the source site's own form — with or without the trailing
1319
+ * slash — is what its canonical, og:url and hreflang must name.
1320
+ */
1321
+ export function pagePath(pathname: string, trailingSlash = true): string {
1322
+ const path = pathname.replace(/\\/index\\.html$/, '/').replace(/\\.html$/, '')
1323
+ if (path === '/' || path === '') return '/'
1324
+ const bare = path.replace(/\\/+$/, '')
1325
+ return trailingSlash ? bare + '/' : bare
1326
+ }
1327
+
1328
+ /** One page as a feed item or an llms.txt link. */
1329
+ export interface SiteLink {
1330
+ title: string
1331
+ url: string
1332
+ description?: string
1333
+ /** ISO 8601. */
1334
+ date?: string
1335
+ author?: string
1336
+ }
1337
+
1338
+ /**
1339
+ * A collection's entries as absolute links, newest first; undated entries
1340
+ * keep their order after the dated ones. Only the entries in \`locale\` —
1341
+ * an entry without one is in the site's default, \`siteLocale\` — and with
1342
+ * \`indexedOnly\`, none the source kept out of search.
1343
+ */
1344
+ export function entryLinks(posts: EmittedPost[], pattern: string, site: URL | undefined, locale: string, siteLocale: string, indexedOnly = false, trailingSlash = true): SiteLink[] {
1345
+ const links: SiteLink[] = []
1346
+ for (const post of posts) {
1347
+ if ((post.locale ?? siteLocale) !== locale) continue
1348
+ if (indexedOnly && post.noindex) continue
1349
+ const url = absoluteUrl(entryAddress(pattern, { ...(post.params ?? {}), slug: post.slug }, trailingSlash), site)
1350
+ if (!url) continue
1351
+ links.push({
1352
+ title: post.title,
1353
+ url,
1354
+ description: seoDescription(post.description ?? post.excerpt),
1355
+ date: post.published_at,
1356
+ author: post.author,
1357
+ })
1358
+ }
1359
+ const time = (link: SiteLink) => (link.date ? Date.parse(link.date) || 0 : 0)
1360
+ return links
1361
+ .map((link, index) => ({ link, index }))
1362
+ .sort((a, b) => time(b.link) - time(a.link) || a.index - b.index)
1363
+ .map((entry) => entry.link)
1364
+ }
1365
+
1366
+ /** Text for XML: escaped, and without the control characters XML 1.0 forbids. */
1367
+ const xmlText = (value: string): string => value
1368
+ .replace(/[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]/g, '')
1369
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
1370
+
1371
+ export interface RssChannel {
1372
+ title: string
1373
+ /** The site's home page, absolute. */
1374
+ link: string
1375
+ /** The feed's own address, absolute. */
1376
+ self: string
1377
+ description?: string
1378
+ language?: string
1379
+ items: SiteLink[]
1380
+ }
1381
+
1382
+ /**
1383
+ * RSS 2.0, the format WordPress serves at /feed/. The channel's
1384
+ * \`lastBuildDate\` is its newest item's date, so rebuilding unchanged
1385
+ * content writes the same bytes.
1386
+ */
1387
+ export function rssFeed(channel: RssChannel): string {
1388
+ const rfc822 = (iso: string | undefined): string | undefined => {
1389
+ const time = iso ? Date.parse(iso) : NaN
1390
+ return Number.isNaN(time) ? undefined : new Date(time).toUTCString()
1391
+ }
1392
+ const tag = (name: string, value: string | undefined) => (value ? '<' + name + '>' + xmlText(value) + '</' + name + '>' : '')
1393
+ const newest = channel.items.map((item) => rfc822(item.date)).find(Boolean)
1394
+ const items = channel.items.map((item) => [
1395
+ '<item>',
1396
+ tag('title', item.title),
1397
+ tag('link', item.url),
1398
+ '<guid isPermaLink="true">' + xmlText(item.url) + '</guid>',
1399
+ tag('pubDate', rfc822(item.date)),
1400
+ tag('dc:creator', item.author),
1401
+ tag('description', item.description),
1402
+ '</item>',
1403
+ ].filter(Boolean).join(''))
1404
+ return [
1405
+ '<?xml version="1.0" encoding="UTF-8"?>',
1406
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">',
1407
+ '<channel>',
1408
+ tag('title', channel.title),
1409
+ tag('link', channel.link),
1410
+ '<atom:link href="' + xmlText(channel.self) + '" rel="self" type="application/rss+xml"/>',
1411
+ // Required by RSS 2.0; a site without a tagline gets its title.
1412
+ tag('description', channel.description || channel.title),
1413
+ tag('language', channel.language),
1414
+ tag('lastBuildDate', newest),
1415
+ ...items,
1416
+ '</channel>',
1417
+ '</rss>',
1418
+ ].filter(Boolean).join('\\n') + '\\n'
1419
+ }
1420
+
1421
+ export interface LlmsSection {
1422
+ name: string
1423
+ links: SiteLink[]
1424
+ }
1425
+
1426
+ /**
1427
+ * /llms.txt (llmstxt.org): the site's name, its tagline, and a section of
1428
+ * links per kind of page — what a language model reads to find its way
1429
+ * around the site, as Yoast's generator writes it.
1430
+ */
1431
+ export function llmsTxt(input: { title: string; description?: string; sections: LlmsSection[] }): string {
1432
+ const line = (value: string) => value.replace(/\\s+/g, ' ').trim()
1433
+ const label = (value: string) => line(value).replace(/[\\[\\]\\\\]/g, '\\\\$&')
1434
+ const href = (url: string) => url.replace(/\\(/g, '%28').replace(/\\)/g, '%29').replace(/\\s/g, '%20')
1435
+ const out = ['# ' + line(input.title)]
1436
+ if (input.description && line(input.description)) out.push('', '> ' + line(input.description))
1437
+ for (const section of input.sections) {
1438
+ if (!section.links.length) continue
1439
+ out.push('', '## ' + line(section.name), '')
1440
+ for (const link of section.links) {
1441
+ const description = link.description ? line(link.description) : ''
1442
+ out.push('- [' + label(link.title) + '](' + href(link.url) + ')' + (description ? ': ' + description : ''))
1443
+ }
1444
+ }
1445
+ return out.join('\\n') + '\\n'
1446
+ }
1447
+
492
1448
  /** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */
493
1449
  export const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)
494
1450
 
@@ -565,8 +1521,20 @@ export interface EntryRef {
565
1521
  locale?: string
566
1522
  }
567
1523
 
1524
+ /** Share-card values a page's source set by hand; each one left out is derived. */
1525
+ export interface SocialOverride {
1526
+ title?: string
1527
+ description?: string
1528
+ image?: string
1529
+ }
1530
+
1531
+ export interface TwitterOverride extends SocialOverride {
1532
+ card?: string
1533
+ }
1534
+
568
1535
  export interface SeoInput {
569
1536
  title?: string
1537
+ headline?: string
570
1538
  description?: string
571
1539
  canonical?: string
572
1540
  image?: string
@@ -578,6 +1546,13 @@ export interface SeoInput {
578
1546
  publishedAt?: string
579
1547
  modifiedAt?: string
580
1548
  author?: string
1549
+ authorUrl?: string
1550
+ profile?: AuthorProfile
1551
+ noindex?: boolean
1552
+ nofollow?: boolean
1553
+ openGraph?: SocialOverride
1554
+ twitter?: TwitterOverride
1555
+ schema?: unknown
581
1556
  }
582
1557
 
583
1558
  /**
@@ -586,10 +1561,12 @@ export interface SeoInput {
586
1561
  * only when it already reads as a path; otherwise \`image\` supplies it and
587
1562
  * the tag is omitted rather than pointing at a URL that does not resolve.
588
1563
  */
589
- export function postSeo(post: EmittedPost): SeoInput {
1564
+ export function postSeo(post: EmittedPost, kind: 'post' | 'page' = 'post'): SeoInput {
590
1565
  const featured = (post.featured ?? []).find((f) => f.startsWith('/') || /^https?:/i.test(f))
591
1566
  return {
592
- title: post.title,
1567
+ // The document title an SEO plugin composed; the entry's own name stays the headline.
1568
+ title: post.seo_title || post.title,
1569
+ headline: post.title,
593
1570
  description: post.description ?? post.excerpt,
594
1571
  canonical: post.canonical,
595
1572
  image: post.image ?? featured,
@@ -597,10 +1574,18 @@ export function postSeo(post: EmittedPost): SeoInput {
597
1574
  imageMeta: post.image ? post.image_meta : undefined,
598
1575
  alternates: post.alternates,
599
1576
  breadcrumbs: post.breadcrumbs,
600
- type: 'article',
1577
+ // A WordPress page is a web page, not an article: og:type website, no
1578
+ // Article node, no article:* tags — as the source's SEO plugin printed it.
1579
+ type: kind === 'page' ? 'website' : 'article',
601
1580
  publishedAt: post.published_at,
602
1581
  modifiedAt: post.modified_at,
603
1582
  author: post.author,
1583
+ authorUrl: post.author_url,
1584
+ noindex: post.noindex,
1585
+ nofollow: post.nofollow,
1586
+ openGraph: post.open_graph,
1587
+ twitter: post.twitter,
1588
+ schema: post.schema,
604
1589
  }
605
1590
  }
606
1591
 
@@ -617,6 +1602,14 @@ export interface EmittedPost extends MarkablePost {
617
1602
  breadcrumbs?: Breadcrumb[]
618
1603
  /** Canonical override; default is the page's own address. */
619
1604
  canonical?: string
1605
+ /** The author's page (their archive), absolute or site-root-relative. */
1606
+ author_url?: string
1607
+ /** Document title, when the source composed one apart from \`title\`. */
1608
+ seo_title?: string
1609
+ open_graph?: SocialOverride
1610
+ twitter?: TwitterOverride
1611
+ /** The source SEO plugin's JSON-LD for this page. */
1612
+ schema?: unknown
620
1613
  /** ISO 8601, for Article structured data. */
621
1614
  published_at?: string
622
1615
  modified_at?: string
@@ -627,6 +1620,9 @@ export interface EmittedPost extends MarkablePost {
627
1620
  locale?: string
628
1621
  /** Present when the producer bound this post to the content store. */
629
1622
  entry?: EntryRef
1623
+ /** Kept out of search: robots meta, and no sitemap entry. */
1624
+ noindex?: boolean
1625
+ nofollow?: boolean
630
1626
  }
631
1627
 
632
1628
  /** One static path of a list route, as it appears in the emitted query data. */
@@ -643,6 +1639,13 @@ export interface EmittedQueryPage {
643
1639
  image_meta?: ImageMeta
644
1640
  canonical?: string
645
1641
  breadcrumbs?: Breadcrumb[]
1642
+ noindex?: boolean
1643
+ nofollow?: boolean
1644
+ open_graph?: SocialOverride
1645
+ twitter?: TwitterOverride
1646
+ schema?: unknown
1647
+ /** On an author's archive: who the page is about. */
1648
+ profile?: AuthorProfile
646
1649
  }
647
1650
  `;
648
1651
  //#endregion
@@ -762,9 +1765,13 @@ function balanceWarning(html) {
762
1765
  }
763
1766
  //#endregion
764
1767
  //#region src/seo.ts
765
- /** Tags the emitter owns; a source copy of any of these is replaced, not duplicated. */
1768
+ /**
1769
+ * Tags the emitter owns; a source copy of any of these is replaced, not
1770
+ * duplicated. `twitter:site` is the site's handle, the same on every page and
1771
+ * not derivable from an entry, so it stays in the head.
1772
+ */
766
1773
  const TITLE_RE = /<title\b[^>]*>[\s\S]*?<\/title>\s*/gi;
767
- const META_RE = /<meta\b[^>]*\b(?:name|property)\s*=\s*["'](?:description|og:[^"']*|twitter:[^"']*)["'][^>]*>\s*/gi;
1774
+ const META_RE = /<meta\b[^>]*\b(?:name|property)\s*=\s*["'](?:description|robots|googlebot|og:[^"']*|twitter:(?!site["'])[^"']*)["'][^>]*>\s*/gi;
768
1775
  const CANONICAL_RE = /<link\b[^>]*\brel\s*=\s*["'][^"']*\bcanonical\b[^"']*["'][^>]*>\s*/gi;
769
1776
  /**
770
1777
  * A translation link names the template page's translations. An RSS
@@ -913,7 +1920,55 @@ function decideLd(json) {
913
1920
  * — the node the page's WebPage says it is part of. Only what the head says:
914
1921
  * no id, no link.
915
1922
  */
916
- function websiteIdOf(html) {
1923
+ function websiteIdOf(html) {
1924
+ for (const match of html.matchAll(JSONLD_RE)) {
1925
+ let parsed;
1926
+ try {
1927
+ parsed = JSON.parse(match[1] ?? "");
1928
+ } catch {
1929
+ continue;
1930
+ }
1931
+ const graph = parsed?.["@graph"];
1932
+ const nodes = Array.isArray(parsed) ? parsed : Array.isArray(graph) ? graph : [parsed];
1933
+ for (const node of nodes) {
1934
+ const id = node?.["@id"];
1935
+ if (typeof id === "string" && id && typesOf(node).some((t) => t.toLowerCase() === "website")) return id;
1936
+ }
1937
+ }
1938
+ }
1939
+ /**
1940
+ * \`@id\` of every node in a head's structured data — the site's own nodes
1941
+ * (WebSite, Organization, its logo) that every page already carries. A page's
1942
+ * plugin graph repeats them; printing them twice is valid but is noise.
1943
+ */
1944
+ function headLdIdsOf(html) {
1945
+ const ids = [];
1946
+ for (const match of html.matchAll(JSONLD_RE)) {
1947
+ let parsed;
1948
+ try {
1949
+ parsed = JSON.parse(match[1] ?? "");
1950
+ } catch {
1951
+ continue;
1952
+ }
1953
+ const graph = parsed?.["@graph"];
1954
+ const nodes = Array.isArray(parsed) ? parsed : Array.isArray(graph) ? graph : [parsed];
1955
+ for (const node of nodes) {
1956
+ const id = node?.["@id"];
1957
+ if (typeof id === "string" && id && !ids.includes(id)) ids.push(id);
1958
+ }
1959
+ }
1960
+ return ids;
1961
+ }
1962
+ /**
1963
+ * `@id` of the node that publishes the site, as the head's structured data
1964
+ * says: the WebSite's own `publisher` reference (Yoast and Rank Math write
1965
+ * one — an Organization, or the Person a personal site is published by),
1966
+ * else the head's first Organization. The Article names it by `@id`, so the
1967
+ * logo and profile the site declared count for every post.
1968
+ */
1969
+ function publisherIdOf(html) {
1970
+ const declared = new Set(headLdIdsOf(html));
1971
+ let organization;
917
1972
  for (const match of html.matchAll(JSONLD_RE)) {
918
1973
  let parsed;
919
1974
  try {
@@ -924,15 +1979,39 @@ function websiteIdOf(html) {
924
1979
  const graph = parsed?.["@graph"];
925
1980
  const nodes = Array.isArray(parsed) ? parsed : Array.isArray(graph) ? graph : [parsed];
926
1981
  for (const node of nodes) {
1982
+ const types = typesOf(node).map((t) => t.toLowerCase());
1983
+ if (types.includes("website")) {
1984
+ const ref = node["publisher"];
1985
+ const id = Array.isArray(ref) ? ref[0] : ref;
1986
+ if (typeof id?.["@id"] === "string" && declared.has(id["@id"])) return id["@id"];
1987
+ }
927
1988
  const id = node?.["@id"];
928
- if (typeof id === "string" && id && typesOf(node).some((t) => t.toLowerCase() === "website")) return id;
1989
+ if (!organization && typeof id === "string" && id && isSiteNode(node) && !types.includes("website")) organization = id;
929
1990
  }
930
1991
  }
1992
+ return organization;
1993
+ }
1994
+ /** Directives that say whether THIS page is indexed or followed; everything else is a site setting. */
1995
+ const PAGE_ROBOTS = new Set([
1996
+ "index",
1997
+ "noindex",
1998
+ "follow",
1999
+ "nofollow",
2000
+ "all",
2001
+ "none"
2002
+ ]);
2003
+ function siteRobots(tag, into) {
2004
+ const content = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
2005
+ for (const raw of (content?.[1] ?? content?.[2] ?? "").split(",")) {
2006
+ const directive = raw.trim().toLowerCase();
2007
+ if (directive && !PAGE_ROBOTS.has(directive) && !into.includes(directive)) into.push(directive);
2008
+ }
931
2009
  }
932
2010
  /** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */
933
2011
  function stripSeoTags(html) {
934
2012
  const removed = [];
935
2013
  const kept = [];
2014
+ const robots = [];
936
2015
  const note = (label) => {
937
2016
  if (!removed.includes(label)) removed.push(label);
938
2017
  };
@@ -942,6 +2021,7 @@ function stripSeoTags(html) {
942
2021
  });
943
2022
  out = out.replace(META_RE, (tag) => {
944
2023
  const name = /\b(?:name|property)\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1] ?? "meta";
2024
+ if (/^(?:robots|googlebot)$/i.test(name)) siteRobots(tag, robots);
945
2025
  note(name.toLowerCase().startsWith("og:") ? "og:*" : name.toLowerCase().startsWith("twitter:") ? "twitter:*" : name);
946
2026
  return "";
947
2027
  });
@@ -965,7 +2045,8 @@ function stripSeoTags(html) {
965
2045
  return {
966
2046
  html: out,
967
2047
  removed,
968
- kept
2048
+ kept,
2049
+ robots
969
2050
  };
970
2051
  }
971
2052
  /**
@@ -1001,10 +2082,13 @@ const SEO_COMPONENT = `---
1001
2082
  * Canonical and absolute URLs need \`site\` in astro.config.mjs; without it the
1002
2083
  * canonical link and og:url are omitted rather than pointing at a build host.
1003
2084
  */
1004
- import { absoluteUrl, imageMetaTags, jsonLd, pageStructuredData, seoDescription, type Breadcrumb, type ImageMeta } from '../lib/fill'
2085
+ import { absoluteUrl, imageMetaTags, jsonLd, ogLocale, pagePath, pageStructuredData, seoDescription, sourceStructuredData, type AuthorProfile, type Breadcrumb, type ImageMeta, type SocialOverride, type TwitterOverride } from '../lib/fill'
1005
2086
 
1006
2087
  interface Props {
2088
+ /** The document title — an SEO plugin's composed one when the source had it. */
1007
2089
  title?: string
2090
+ /** The entry's own title, when it differs from \`title\`: Article headline, last breadcrumb. */
2091
+ headline?: string
1008
2092
  /** Meta description; falls back to the entry's excerpt, tags stripped. */
1009
2093
  description?: string
1010
2094
  /** Overrides the generated address — for a page that canonicalises elsewhere. */
@@ -1021,18 +2105,42 @@ interface Props {
1021
2105
  pageType?: 'WebPage' | 'CollectionPage'
1022
2106
  /** \`@id\` of the site's WebSite node kept in the head chrome, when there is one. */
1023
2107
  websiteId?: string
2108
+ /** \`@id\` of the node the kept head says publishes the site — the Article's publisher. */
2109
+ publisherId?: string
2110
+ /** false: the site's addresses end without a slash (\`/hello\`), as the source's did. */
2111
+ trailingSlash?: boolean
2112
+ /** \`@id\` of every structured-data node the head chrome keeps — not repeated from \`schema\`. */
2113
+ headLdIds?: string[]
1024
2114
  /** \`article\` on entry pages, \`website\` on lists and static pages. */
1025
2115
  type?: 'article' | 'website'
1026
2116
  /** ISO 8601 — structured data only; the displayed date stays a mark. */
1027
2117
  publishedAt?: string
1028
2118
  modifiedAt?: string
1029
2119
  author?: string
2120
+ /** The author's page, for the Article author's \`url\`. */
2121
+ authorUrl?: string
2122
+ /** On an author's archive: who the page is about — ProfilePage + Person. */
2123
+ profile?: AuthorProfile
2124
+ /** This page's own feed (an archive's), site-root-relative: \`<link rel="alternate" type="application/rss+xml">\`. */
2125
+ feed?: { path: string; title?: string }
1030
2126
  siteName?: string
1031
2127
  locale?: string
2128
+ /** The page is kept out of search: \`<meta name="robots" content="noindex">\`. */
2129
+ noindex?: boolean
2130
+ nofollow?: boolean
2131
+ /** Site-wide robots directives from the template head (\`max-image-preview:large\`, …), printed on every page. */
2132
+ robotsDefault?: string[]
2133
+ /** Share-card values the source set by hand, over the derived ones. */
2134
+ openGraph?: SocialOverride
2135
+ /** Unset values follow Open Graph. */
2136
+ twitter?: TwitterOverride
2137
+ /** The source SEO plugin's JSON-LD for this page — printed instead of the generated graph. */
2138
+ schema?: unknown
1032
2139
  }
1033
2140
 
1034
2141
  const {
1035
2142
  title = '',
2143
+ headline,
1036
2144
  description,
1037
2145
  canonical,
1038
2146
  image,
@@ -1041,68 +2149,305 @@ const {
1041
2149
  breadcrumbs,
1042
2150
  pageType,
1043
2151
  websiteId,
2152
+ publisherId,
2153
+ trailingSlash = true,
2154
+ headLdIds = [],
1044
2155
  type = 'website',
1045
2156
  publishedAt,
1046
2157
  modifiedAt,
1047
2158
  author,
2159
+ authorUrl,
2160
+ profile,
2161
+ feed,
1048
2162
  siteName,
1049
2163
  locale,
2164
+ noindex = false,
2165
+ nofollow = false,
2166
+ robotsDefault = [],
2167
+ openGraph,
2168
+ twitter,
2169
+ schema,
1050
2170
  } = Astro.props
1051
2171
 
1052
2172
  const site = Astro.site
1053
- const url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(Astro.url.pathname, site)
2173
+ const url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(pagePath(Astro.url.pathname, trailingSlash), site)
1054
2174
  const imageUrl = absoluteUrl(image, site)
1055
2175
  const imageTags = imageUrl ? imageMetaTags(imageMeta) : {}
1056
2176
  // hreflang needs absolute URLs; without \`site\` there are none to give.
1057
2177
  const hreflang = alternates.flatMap((a) => {
1058
- const href = absoluteUrl(a.path, site)
2178
+ const href = absoluteUrl(pagePath(a.path, trailingSlash), site)
1059
2179
  return href ? [{ lang: a.lang, href }] : []
1060
2180
  })
1061
- const localeAlternates = [...new Set(hreflang.map((a) => a.lang))].filter((l) => l !== 'x-default' && l !== locale)
2181
+ // og:locale takes ll_RR; a language without a region has no such form and is left out.
2182
+ const ogLang = ogLocale(locale)
2183
+ const localeAlternates = [...new Set(hreflang.map((a) => ogLocale(a.lang)))].filter((l): l is string => Boolean(l) && l !== ogLang)
1062
2184
  const desc = seoDescription(description)
2185
+ const robots = [noindex && 'noindex', nofollow && 'nofollow', ...robotsDefault].filter(Boolean).join(', ')
2186
+ // Share cards: what the source set by hand, else what the page says.
2187
+ const ogTitle = openGraph?.title || title
2188
+ const ogDesc = seoDescription(openGraph?.description) ?? desc
2189
+ const ogImage = absoluteUrl(openGraph?.image, site) ?? imageUrl
2190
+ // The measurements describe \`image\`; an override is another file.
2191
+ const ogImageTags = ogImage === imageUrl ? imageTags : {}
2192
+ const twTitle = twitter?.title || ogTitle
2193
+ const twDesc = seoDescription(twitter?.description) ?? ogDesc
2194
+ const twImage = absoluteUrl(twitter?.image, site) ?? ogImage
2195
+ const twCard = twitter?.card === 'summary' || twitter?.card === 'summary_large_image' ? twitter.card : twImage ? 'summary_large_image' : 'summary'
1063
2196
  const article = type === 'article'
1064
- const structured = pageStructuredData({
2197
+ const structured = sourceStructuredData(schema, headLdIds) ?? pageStructuredData({
1065
2198
  url,
1066
2199
  site,
1067
2200
  title,
2201
+ headline,
1068
2202
  description: desc,
1069
2203
  image: imageUrl,
2204
+ imageMeta: imageUrl ? imageMeta : undefined,
1070
2205
  locale,
1071
2206
  article,
1072
2207
  pageType,
1073
2208
  publishedAt,
1074
2209
  modifiedAt,
1075
2210
  author,
2211
+ authorUrl,
2212
+ profile,
2213
+ trailingSlash,
1076
2214
  siteName,
1077
2215
  websiteId,
2216
+ publisherId,
1078
2217
  breadcrumbs,
1079
2218
  })
1080
2219
  ---
1081
2220
  <title>{title}</title>
2221
+ {robots && <meta name="robots" content={robots} />}
1082
2222
  {desc && <meta name="description" content={desc} />}
1083
2223
  {url && <link rel="canonical" href={url} />}
1084
2224
  {hreflang.map((a) => <link rel="alternate" hreflang={a.lang} href={a.href} />)}
2225
+ {feed && <link rel="alternate" type="application/rss+xml" title={feed.title} href={feed.path} />}
1085
2226
  <meta property="og:type" content={type} />
1086
- {title && <meta property="og:title" content={title} />}
1087
- {desc && <meta property="og:description" content={desc} />}
2227
+ {ogTitle && <meta property="og:title" content={ogTitle} />}
2228
+ {ogDesc && <meta property="og:description" content={ogDesc} />}
1088
2229
  {url && <meta property="og:url" content={url} />}
1089
- {imageUrl && <meta property="og:image" content={imageUrl} />}
1090
- {imageTags.width && <meta property="og:image:width" content={imageTags.width} />}
1091
- {imageTags.height && <meta property="og:image:height" content={imageTags.height} />}
1092
- {imageTags.type && <meta property="og:image:type" content={imageTags.type} />}
2230
+ {ogImage && <meta property="og:image" content={ogImage} />}
2231
+ {ogImageTags.width && <meta property="og:image:width" content={ogImageTags.width} />}
2232
+ {ogImageTags.height && <meta property="og:image:height" content={ogImageTags.height} />}
2233
+ {ogImageTags.type && <meta property="og:image:type" content={ogImageTags.type} />}
1093
2234
  {siteName && <meta property="og:site_name" content={siteName} />}
1094
- {locale && <meta property="og:locale" content={locale} />}
2235
+ {ogLang && <meta property="og:locale" content={ogLang} />}
1095
2236
  {localeAlternates.map((l) => <meta property="og:locale:alternate" content={l} />)}
1096
2237
  {article && publishedAt && <meta property="article:published_time" content={publishedAt} />}
1097
2238
  {article && modifiedAt && <meta property="article:modified_time" content={modifiedAt} />}
1098
2239
  {article && author && <meta property="article:author" content={author} />}
1099
- <meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
1100
- {title && <meta name="twitter:title" content={title} />}
1101
- {desc && <meta name="twitter:description" content={desc} />}
1102
- {imageUrl && <meta name="twitter:image" content={imageUrl} />}
2240
+ <meta name="twitter:card" content={twCard} />
2241
+ {twTitle && <meta name="twitter:title" content={twTitle} />}
2242
+ {twDesc && <meta name="twitter:description" content={twDesc} />}
2243
+ {twImage && <meta name="twitter:image" content={twImage} />}
1103
2244
  {structured && <script is:inline type="application/ld+json" set:html={jsonLd(structured)} />}
1104
2245
  `;
1105
2246
  //#endregion
2247
+ //#region src/feed.ts
2248
+ /** Where the feed is built. */
2249
+ const FEED_PATH = "/feed.xml";
2250
+ /** WordPress's feed address, redirected (301) to FEED_PATH. */
2251
+ const FEED_REDIRECT_FROM = "/feed/";
2252
+ /** Items in the feed — WordPress's default `posts_per_rss`. */
2253
+ const FEED_ITEMS = 10;
2254
+ /** Links per llms.txt section. A long archive is summarised by its newest pages. */
2255
+ const LLMS_LINKS = 100;
2256
+ /**
2257
+ * The collections the site builds entry pages for in its default language,
2258
+ * each with the first route that does: posts first, pages next, the rest by
2259
+ * name. A route in another language is left out — the feed and llms.txt
2260
+ * describe the site as it is in its default one.
2261
+ */
2262
+ function linkSources(routes, siteLocale) {
2263
+ const byCollection = /* @__PURE__ */ new Map();
2264
+ for (const route of routes) {
2265
+ if (route.kind !== "single" && route.collection === void 0) continue;
2266
+ if ((route.locale ?? siteLocale) !== siteLocale) continue;
2267
+ const pagePath = patternToPagePath(route.pattern);
2268
+ if (!pagePath || !pagePath.includes("[")) continue;
2269
+ const collection = route.collection ?? "posts";
2270
+ if (!byCollection.has(collection)) byCollection.set(collection, route.pattern);
2271
+ }
2272
+ const rank = (c) => c === "posts" ? 0 : c === "pages" ? 1 : 2;
2273
+ return [...byCollection].map(([collection, pattern]) => ({
2274
+ collection,
2275
+ pattern
2276
+ })).toSorted((a, b) => rank(a.collection) - rank(b.collection) || (a.collection < b.collection ? -1 : a.collection > b.collection ? 1 : 0));
2277
+ }
2278
+ /**
2279
+ * Archive routes in the site's default language whose every page is an
2280
+ * address with a feed: a query route with parameters and no page number —
2281
+ * `/category/:term*`, `/tag/:term`, `/author/:author`. The paginated
2282
+ * continuation (`/category/:term/page/:page`) has no feed of its own.
2283
+ */
2284
+ function archiveFeedSources(routes, siteLocale) {
2285
+ const out = [];
2286
+ for (const route of routes) {
2287
+ if (!route.query || (route.locale ?? siteLocale) !== siteLocale) continue;
2288
+ const pagePath = patternToPagePath(route.pattern);
2289
+ if (!pagePath || !pagePath.includes("[")) continue;
2290
+ if (route.pattern.split("/").some((seg) => /^:page\*?$/.test(seg))) continue;
2291
+ out.push({
2292
+ routeId: route.id,
2293
+ pattern: route.pattern,
2294
+ query: route.query
2295
+ });
2296
+ }
2297
+ return out;
2298
+ }
2299
+ /** Where an archive route's feed endpoint lives: its page path with `/feed.xml` appended. */
2300
+ function archiveFeedFile(pattern) {
2301
+ const pagePath = patternToPagePath(pattern);
2302
+ return pagePath ? `src/pages/${pagePath.replace(/\.astro$/, "")}/feed.xml.ts` : null;
2303
+ }
2304
+ /**
2305
+ * The feed of every page an archive route builds — the newest posts it lists,
2306
+ * at their own addresses — at `<archive>/feed.xml`. Its entries are the
2307
+ * route's own first result set, so the feed lists what the archive page does.
2308
+ */
2309
+ function archiveFeedEndpoint(source, postPattern, input) {
2310
+ const locale = JSON.stringify(input.siteLocale);
2311
+ const file = archiveFeedFile(source.pattern);
2312
+ const up = "../".repeat(file.split("/").length - 2);
2313
+ const slash = input.trailingSlash === false ? ", false, false" : "";
2314
+ return `// Archive feed for route ${source.routeId} (${source.pattern}) — emitted by @contentrain/emitter-astro.
2315
+ // One feed per page the archive builds, at <archive>/feed.xml, as WordPress serves
2316
+ // one at <archive>/feed/: the 10 newest posts the archive lists.
2317
+ import type { APIRoute, GetStaticPaths } from 'astro'
2318
+ import data from '${up}data/queries/${source.query}.json'
2319
+ import { absoluteUrl, entryAddress, entryLinks, rssFeed, type EmittedQueryPage } from '${up}lib/fill'
2320
+
2321
+ export const getStaticPaths = (() => (data as EmittedQueryPage[]).map((page) => ({ params: page.params, props: { page } }))) satisfies GetStaticPaths
2322
+
2323
+ export const GET: APIRoute = ({ props, site }) => {
2324
+ const page = (props as { page: EmittedQueryPage }).page
2325
+ const address = entryAddress(${JSON.stringify(source.pattern)}, page.params) ?? '/'
2326
+ const items = entryLinks(page.items, ${JSON.stringify(postPattern)}, site, ${locale}, ${locale}${slash}).slice(0, 10)
2327
+ const body = rssFeed({
2328
+ title: page.title || ${JSON.stringify(siteTitle(input.ir))},
2329
+ link: absoluteUrl(address, site) ?? '',
2330
+ self: absoluteUrl(address + 'feed.xml', site) ?? '',
2331
+ description: page.description ?? ${JSON.stringify(input.description ?? "")},
2332
+ language: ${locale},
2333
+ items,
2334
+ })
2335
+ return new Response(body, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } })
2336
+ }
2337
+ `;
2338
+ }
2339
+ const sectionName = (collection) => collection.charAt(0).toUpperCase() + collection.slice(1).replace(/[-_]+/g, " ");
2340
+ function siteTitle(ir) {
2341
+ if (ir.site.title?.trim()) return ir.site.title.trim();
2342
+ try {
2343
+ return new URL(ir.site.url).host;
2344
+ } catch {
2345
+ return ir.site.url;
2346
+ }
2347
+ }
2348
+ /** `src/pages/feed.xml.ts`: the newest posts as RSS 2.0. */
2349
+ function feedEndpoint(source, input) {
2350
+ const locale = JSON.stringify(input.siteLocale);
2351
+ return `// RSS feed at ${FEED_PATH} — emitted by @contentrain/emitter-astro.
2352
+ // Built from the data the post pages are built from: the 10 newest posts,
2353
+ // each at the address its own page has.
2354
+ import type { APIRoute } from 'astro'
2355
+ import data from '../data/${source.collection}.json'
2356
+ import { entryLinks, rssFeed, type EmittedPost } from '../lib/fill'
2357
+
2358
+ export const GET: APIRoute = ({ site }) => {
2359
+ const items = entryLinks(data as EmittedPost[], ${JSON.stringify(source.pattern)}, site, ${locale}, ${locale}${input.trailingSlash === false ? ", false, false" : ""}).slice(0, 10)
2360
+ const body = rssFeed({
2361
+ title: ${JSON.stringify(siteTitle(input.ir))},
2362
+ link: new URL('/', site).toString(),
2363
+ self: new URL(${JSON.stringify(FEED_PATH)}, site).toString(),
2364
+ description: ${JSON.stringify(input.description ?? "")},
2365
+ language: ${locale},
2366
+ items,
2367
+ })
2368
+ return new Response(body, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } })
2369
+ }
2370
+ `;
2371
+ }
2372
+ /** `src/pages/llms.txt.ts`: a section of links per collection. */
2373
+ function llmsEndpoint(sources, input) {
2374
+ const locale = JSON.stringify(input.siteLocale);
2375
+ const imports = sources.map((s, i) => `import c${i} from '../data/${s.collection}.json'`);
2376
+ const sections = sources.map((s, i) => ` { name: ${JSON.stringify(sectionName(s.collection))}, links: entryLinks(c${i} as EmittedPost[], ${JSON.stringify(s.pattern)}, site, ${locale}, ${locale}, true${input.trailingSlash === false ? ", false" : ""}).slice(0, 100) },`);
2377
+ return `// /llms.txt (llmstxt.org) — emitted by @contentrain/emitter-astro.
2378
+ // What a language model reads to find its way around the site: its name, its
2379
+ // tagline, and the newest 100 pages of each kind, at their own addresses —
2380
+ // none the source kept out of search (noindex).
2381
+ import type { APIRoute } from 'astro'
2382
+ ${imports.join("\n")}
2383
+ import { entryLinks, llmsTxt, type EmittedPost } from '../lib/fill'
2384
+
2385
+ export const GET: APIRoute = ({ site }) => {
2386
+ const body = llmsTxt({
2387
+ title: ${JSON.stringify(siteTitle(input.ir))},
2388
+ description: ${JSON.stringify(input.description ?? "")},
2389
+ sections: [
2390
+ ${sections.join("\n")}
2391
+ ],
2392
+ })
2393
+ return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
2394
+ }
2395
+ `;
2396
+ }
2397
+ const FEED_LINK_RE = /<link\b(?=[^>]*\brel\s*=\s*["']?[^"'>]*\balternate\b)(?=[^>]*\btype\s*=\s*["']?application\/(?:rss|atom)\+xml)[^>]*>/gi;
2398
+ const HREF_RE = /(\bhref\s*=\s*)(["'])([^"']*)\2/i;
2399
+ /** www. and the bare host are one site: a WordPress head often names the other. */
2400
+ const siteHost = (url) => url.host.toLowerCase().replace(/^www\./, "");
2401
+ /**
2402
+ * The feed a head link names, when it is one of the site's own: `main` for
2403
+ * WordPress's main RSS feed (/feed/, /feed/rss2/, ?feed=rss2), `other` for
2404
+ * the rest it serves (comments, a category, Atom). A feed on another host —
2405
+ * FeedBurner, a newsletter tool — is not the site's to build or to break.
2406
+ */
2407
+ function ownFeed(href, type, site) {
2408
+ let url;
2409
+ try {
2410
+ url = new URL(href, site);
2411
+ } catch {
2412
+ return;
2413
+ }
2414
+ if (siteHost(url) !== siteHost(site)) return void 0;
2415
+ const path = url.pathname.replace(/\/+$/, "");
2416
+ const query = url.searchParams.get("feed");
2417
+ return (path === "/feed" || path === "/feed/rss2" || path === "/feed/rss" || path === "" && (query === "rss2" || query === "rss")) && /rss\+xml/i.test(type) ? "main" : "other";
2418
+ }
2419
+ /** Point the head's link to the site's main feed at the one the build writes, and remove the template page's others. */
2420
+ function rewriteFeedLinks(html, siteUrl) {
2421
+ let site;
2422
+ try {
2423
+ site = new URL(siteUrl);
2424
+ } catch {
2425
+ return {
2426
+ html,
2427
+ rewritten: 0,
2428
+ other: 0
2429
+ };
2430
+ }
2431
+ let rewritten = 0;
2432
+ let other = 0;
2433
+ return {
2434
+ html: html.replace(FEED_LINK_RE, (tag) => {
2435
+ const href = HREF_RE.exec(tag);
2436
+ const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(tag)?.[1] ?? "";
2437
+ const kind = href ? ownFeed(href[3] ?? "", type, site) : void 0;
2438
+ if (kind === "other") {
2439
+ other++;
2440
+ return "";
2441
+ }
2442
+ if (kind !== "main") return tag;
2443
+ rewritten++;
2444
+ return tag.replace(HREF_RE, `$1$2${FEED_PATH}$2`);
2445
+ }),
2446
+ rewritten,
2447
+ other
2448
+ };
2449
+ }
2450
+ //#endregion
1106
2451
  //#region src/layouts.ts
1107
2452
  /** A component the family's chrome mounts at a `<!--@@component:ID@@-->` marker. */
1108
2453
  /** A safe identifier for a query id used as a module-local variable name. */
@@ -1170,14 +2515,21 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
1170
2515
  }
1171
2516
  for (const placement of family.components ?? []) if (!mounts.some((m) => m.id === placement.component)) warnings.push(`family ${family.id}: component placement "${placement.component}" has no marker in the body chrome or in any content body — not mounted`);
1172
2517
  let head = joined("head");
2518
+ let robotsDefault = [];
1173
2519
  if (seoOn) {
1174
2520
  const stripped = stripSeoTags(head);
1175
2521
  head = stripped.html;
2522
+ robotsDefault = stripped.robots;
1176
2523
  if (stripped.removed.length) {
1177
2524
  const kept = stripped.kept.length ? `; kept the site-wide ${stripped.kept.join(", ")} from its structured data` : "";
1178
2525
  warnings.push(`family ${family.id}: removed the template page's ${stripped.removed.join(", ")} from head chrome — these describe one page, and the emitter renders title, description, canonical, social tags and Article data per page${kept}`);
1179
2526
  }
1180
2527
  }
2528
+ if (options.feedSite) {
2529
+ const feed = rewriteFeedLinks(head, options.feedSite);
2530
+ head = feed.html;
2531
+ if (feed.other) warnings.push(`family ${family.id}: removed ${feed.other} feed links from the head chrome — the template page's own archive feed, or a comments or Atom feed the site does not build; each archive page links its own feed`);
2532
+ }
1181
2533
  const files = {};
1182
2534
  files[`src/data/chrome/${family.id}.json`] = stableJson({
1183
2535
  head,
@@ -1187,8 +2539,14 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
1187
2539
  });
1188
2540
  const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
1189
2541
  const siteNameProp = options.siteName ? ` siteName={${JSON.stringify(options.siteName)}}` : "";
2542
+ const trailingSlashProp = options.trailingSlash === false ? " trailingSlash={false}" : "";
2543
+ const publisherId = seoOn ? publisherIdOf(head) : void 0;
2544
+ const publisherIdProp = publisherId ? ` publisherId={${JSON.stringify(publisherId)}}` : "";
1190
2545
  const websiteId = seoOn ? websiteIdOf(head) : void 0;
1191
2546
  const websiteIdProp = websiteId ? ` websiteId={${JSON.stringify(websiteId)}}` : "";
2547
+ const robotsProp = robotsDefault.length ? ` robotsDefault={${JSON.stringify(robotsDefault)}}` : "";
2548
+ const headLdIds = seoOn ? headLdIdsOf(head) : [];
2549
+ const headLdIdsProp = headLdIds.length ? ` headLdIds={${JSON.stringify(headLdIds)}}` : "";
1192
2550
  const imported = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])];
1193
2551
  const componentImports = [...seoOn ? [`import Seo from '../components/Seo.astro'`] : [], ...imported.map((n) => `import ${n} from '../components/${n}.astro'`)].join("\n");
1194
2552
  const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(" | ");
@@ -1214,7 +2572,7 @@ const parts = splitComponents(html).map((part) => ({
1214
2572
  // Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
1215
2573
  import chrome from '../data/chrome/${family.id}.json'
1216
2574
  import { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ", splitComponents" : ""}${queryMounts.length ? ", renderQuery, type EmittedQueryPage" : ""}${seoOn ? ", type SeoInput" : ""} } from '../lib/fill'
1217
- ${queryImports}${queryImports ? "\n" : ""}
2575
+ ${options.images ? `import { optimizeHtmlImages } from '../lib/optimize-images'\n` : ""}${queryImports}${queryImports ? "\n" : ""}
1218
2576
  ${componentImports ? `${componentImports}\n` : ""}
1219
2577
  interface Props {
1220
2578
  title?: string
@@ -1227,218 +2585,43 @@ interface Props {
1227
2585
  lang?: string
1228
2586
  /** Content-store address of the page's entry — mounted components (comments) key on it. */
1229
2587
  entry?: { model_id: string; entry_id: string; locale?: string }${seoOn ? `
1230
- /** Per-page SEO — see src/components/Seo.astro. */
1231
- seo?: SeoInput` : ""}
1232
- }
1233
- const { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)}${mounts.length ? ", entry" : ""}${seoOn ? ", seo" : ""} } = Astro.props
1234
- const head = renderTemplate(chrome.head, marks)
1235
- // Root attributes carry the theme's layout hooks; values may hold @@marks@@.
1236
- // An explicit lang from the source wins over the project default.
1237
- const htmlAttrs = { lang, ...fillAttrs(chrome.html_attrs, marks) }
1238
- const bodyAttrs = fillAttrs(chrome.body_attrs, marks)
1239
- const content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : '')
1240
- // Split at the marker FIRST, then fill marks per side — filling first would
1241
- // eat the @@body@@ inside the marker and silently drop the content.
1242
- const html = composeBody(chrome.body, marks, content)
1243
- ${queryRender}${mountTable}---
1244
- <!doctype html>
1245
- <html {...htmlAttrs}>
1246
- <head>
1247
- <meta charset="utf-8" />
1248
- <meta name="viewport" content="width=device-width,initial-scale=1" />
1249
- ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
1250
- {css.map((file) => (
1251
- <link rel="stylesheet" href={cssHref(file)} />
1252
- ))}
1253
- <Fragment set:html={head} />
1254
- ${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp}${websiteIdProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
1255
- </head>
1256
- <body {...bodyAttrs}>
1257
- ${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
1258
- ${renderRefs("footer") ? `${renderRefs("footer")}\n` : ""} </body>
1259
- </html>
1260
- `;
1261
- return {
1262
- files,
1263
- warnings
1264
- };
1265
- }
1266
- //#endregion
1267
- //#region src/pages.ts
1268
- function routeFiles(route, family, content, siteLocale, seo = true) {
1269
- const warnings = [];
1270
- const files = {};
1271
- const pagePath = patternToPagePath(route.pattern);
1272
- if (!pagePath) return {
1273
- files,
1274
- warnings: [`route ${route.id}: pattern "${route.pattern}" cannot be expressed as an Astro page — skipped`]
1275
- };
1276
- if (!family) return {
1277
- files,
1278
- warnings: [`route ${route.id}: family "${route.family}" not found — skipped`]
1279
- };
1280
- const layout = pascalCase(family.id);
1281
- const depth = pagePath.split("/").length - 1;
1282
- const up = "../".repeat(depth + 1) || "../";
1283
- const hasParams = pagePath.includes("[");
1284
- const restAt = restParamIndex(pagePath);
1285
- if (restAt !== -1 && restAt !== pagePath.split("/").length - 1) warnings.push(`route ${route.id}: rest parameter is not the final segment of "${route.pattern}" — Astro matches rest parameters greedily; verify this route resolves`);
1286
- if (route.kind === "single" || route.collection !== void 0) {
1287
- const collection = route.collection ?? "posts";
1288
- const posts = collectionItems(content, collection);
1289
- if (!posts.length) warnings.push(`route ${route.id}: collection "${collection}" has no items — page emitted, data file empty`);
1290
- files[`src/data/${collection}.json`] = stableJson(posts);
1291
- files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale, hasParams, seo);
1292
- return {
1293
- files,
1294
- warnings
1295
- };
1296
- }
1297
- const queryPages = route.query ? content.queries?.[route.query] ?? [] : [];
1298
- if (route.query) {
1299
- if (!queryPages.length) warnings.push(`route ${route.id}: query "${route.query}" has no data — page emitted, data file empty`);
1300
- if (queryPages.some((p) => !p.item_template && !p.sections?.length)) warnings.push(`route ${route.id}: item template missing on some pages — plain fallback list rendered (not fidelity)`);
1301
- files[`src/data/queries/${route.query}.json`] = stableJson(queryPages);
1302
- files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale, seo);
1303
- return {
1304
- files,
1305
- warnings
1306
- };
1307
- }
1308
- files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale, seo);
1309
- return {
1310
- files,
1311
- warnings
1312
- };
1313
- }
1314
- /** Items of a named collection; `posts` also accepts the `content.posts` shorthand. */
1315
- function collectionItems(content, collection) {
1316
- const named = content.collections?.[collection];
1317
- if (named) return named;
1318
- return collection === "posts" ? content.posts ?? [] : [];
1319
- }
1320
- function singlePage(route, layout, up, collection, siteLocale, hasParams, seo) {
1321
- const locale = JSON.stringify(route.locale ?? siteLocale);
1322
- return `---
1323
- // Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro
1324
- import Layout from '${up}layouts/${layout}.astro'
1325
- import data from '${up}data/${collection}.json'
1326
- import { postMarks${seo ? ", postSeo" : ""}, type EmittedPost } from '${up}lib/fill'
1327
-
1328
- ${hasParams ? `export function getStaticPaths() {
1329
- // The JSON is data; its contract is EmittedPost. Inferring the type from the
1330
- // file would type only the fields this site happens to carry, and "astro
1331
- // check" (which the build runs first) would then reject post.params on a site
1332
- // whose permalinks need none. The cast lives INSIDE getStaticPaths because
1333
- // Astro hoists this function: it sees imports, not the component scope.
1334
- const posts = data as EmittedPost[]
1335
- // A post carries its own route parameters (date parts of a dated permalink,
1336
- // a post id) — using the template post's would send every other post to the
1337
- // wrong address.
1338
- return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))
1339
- }
1340
- type Props = { post: EmittedPost }
1341
- const { post } = Astro.props as Props` : `const posts = data as EmittedPost[]
1342
- if (posts.length !== 1) throw new Error(${JSON.stringify(`Static content route requires exactly one entry: ${route.id}`)})
1343
- const post = posts[0]!`}
1344
- ---
1345
- <Layout
1346
- title={post.title}
1347
- marks={postMarks(post)}
1348
- body={post.body}
1349
- css={post.css ?? []}
1350
- lang={post.locale ?? ${locale}}
1351
- entry={post.entry}${seo ? `
1352
- seo={postSeo(post)}` : ""}
1353
- />
1354
- `;
1355
- }
1356
- function listPage(route, layout, up, hasParams, siteLocale, seo) {
1357
- const locale = JSON.stringify(route.locale ?? siteLocale);
1358
- const routeTitle = route.title === void 0 ? `''` : JSON.stringify(route.title);
1359
- const paths = hasParams ? `export function getStaticPaths() {
1360
- const pages = data as EmittedQueryPage[]
1361
- return pages.map((page) => ({ params: page.params, props: { page } }))
1362
- }
1363
- type Props = { page: EmittedQueryPage }
1364
- const { page } = Astro.props as Props` : `const page = (data as EmittedQueryPage[])[0] ?? { params: {}, items: [], title: undefined }`;
1365
- return `---
1366
- // Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
1367
- import Layout from '${up}layouts/${layout}.astro'
1368
- import data from '${up}data/queries/${route.query}.json'
1369
- import { renderQueryPage, type EmittedQueryPage } from '${up}lib/fill'
1370
-
1371
- ${paths}
1372
- // A list renders in sections (a big card then a grid) — one template is the
1373
- // single-section case. Without a template at all it falls back to a plain list
1374
- // and the emitter has already warned: silence would read as fidelity.
1375
- const content = renderQueryPage(page)
1376
- // Route params carry slugs; page marks carry what the chrome needs to SHOW
1377
- // (a term's display name, description, count).
1378
- const marks = { ...page.params, ...(page.marks ?? {}) }
1379
- const title = page.title ?? ${routeTitle}
1380
- ---
1381
- <Layout
1382
- title={title || String(marks.term_name ?? page.params.term ?? '')}
1383
- marks={marks}
1384
- body={content}
1385
- css={page.css ?? []}
1386
- lang={${locale}}${seo ? `
1387
- seo={{ description: page.description, image: page.image, imageMeta: page.image_meta, canonical: page.canonical, breadcrumbs: page.breadcrumbs, type: 'website', pageType: 'CollectionPage' }}` : ""}
1388
- />
1389
- `;
1390
- }
1391
- function staticPage(route, layout, up, hasParams, warnings, siteLocale, seo) {
1392
- if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`);
1393
- const paths = hasParams ? `export function getStaticPaths() {
1394
- return []
1395
- }
1396
- ` : "";
1397
- return `---
1398
- // Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
1399
- import Layout from '${up}layouts/${layout}.astro'
1400
- ${paths}---
1401
- <Layout title=${JSON.stringify(route.title ?? "")} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}}${seo ? ` seo={{ type: 'website' }}` : ""} />
1402
- `;
1403
- }
1404
- /** Share a catchall while preserving the original editable collections. */
1405
- function sharedCollectionPage(routes, content, siteLocale, seo = true) {
1406
- const pattern = routes[0]?.pattern;
1407
- if (!pattern || routes.length < 2 || !/^\/(?:[^:*]+\/)*:slug\*$/.test(pattern) || routes.some((r) => r.pattern !== pattern || r.query || r.kind !== "single" && !r.collection)) return void 0;
1408
- const seen = /* @__PURE__ */ new Set();
1409
- for (const route of routes) for (const post of collectionItems(content, route.collection ?? "posts")) {
1410
- let key;
1411
- try {
1412
- key = decodeURIComponent(post.slug).replace(/\/+$/, "");
1413
- } catch {
1414
- return;
1415
- }
1416
- if (seen.has(key)) return void 0;
1417
- seen.add(key);
1418
- }
1419
- const up = "../".repeat(patternToPagePath(pattern).split("/").length);
1420
- return `---
1421
- // Shared collection routes: ${routes.map((r) => r.id).join(", ")}
1422
- ${routes.map((r, i) => `import Layout${i} from '${up}layouts/${pascalCase(r.family)}.astro'\nimport data${i} from '${up}data/${r.collection ?? "posts"}.json'`).join("\n")}
1423
- import { postMarks${seo ? ", postSeo" : ""}, type EmittedPost } from '${up}lib/fill'
1424
- export function getStaticPaths() {
1425
- const sources = [${routes.map((_, i) => `data${i}`).join(", ")}] as EmittedPost[][]
1426
- const seen = new Set<string>()
1427
- return sources.flatMap((posts, routeIndex) => posts.map(post => {
1428
- const key = decodeURIComponent(post.slug).replace(/\\/+$/, '')
1429
- if (seen.has(key)) throw new Error('Route collision in shared collections: ' + post.slug)
1430
- seen.add(key)
1431
- return { params: { ...(post.params ?? {}), slug: post.slug }, props: { post, routeIndex } }
1432
- }))
2588
+ /** Per-page SEO — see src/components/Seo.astro. */
2589
+ seo?: SeoInput` : ""}
1433
2590
  }
1434
- const { post, routeIndex } = Astro.props as { post: EmittedPost, routeIndex: number }
1435
- const layouts = [${routes.map((_, i) => `Layout${i}`).join(", ")}]
1436
- const locales = ${JSON.stringify(routes.map((r) => r.locale ?? siteLocale))}
1437
- const Layout = layouts[routeIndex]!
1438
- ---
1439
- <Layout title={post.title} marks={postMarks(post)} body={post.body}
1440
- css={post.css ?? []} lang={post.locale ?? locales[routeIndex]} entry={post.entry}${seo ? " seo={postSeo(post)}" : ""} />
2591
+ const { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)}${mounts.length ? ", entry" : ""}${seoOn ? ", seo" : ""} } = Astro.props
2592
+ const head = renderTemplate(chrome.head, marks)
2593
+ // Root attributes carry the theme's layout hooks; values may hold @@marks@@.
2594
+ // An explicit lang from the source wins over the project default.
2595
+ const htmlAttrs = { lang, ...fillAttrs(chrome.html_attrs, marks) }
2596
+ const bodyAttrs = fillAttrs(chrome.body_attrs, marks)
2597
+ ${options.images ? `// Content images get srcset, WebP, size and lazy loading at build time; the
2598
+ // theme chrome around them is left as the source had it.
2599
+ const content = await optimizeHtmlImages(body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : ''))` : `const content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('default') : '')`}
2600
+ // Split at the marker FIRST, then fill marks per side — filling first would
2601
+ // eat the @@body@@ inside the marker and silently drop the content.
2602
+ const html = composeBody(chrome.body, marks, content)
2603
+ ${queryRender}${mountTable}---
2604
+ <!doctype html>
2605
+ <html {...htmlAttrs}>
2606
+ <head>
2607
+ <meta charset="utf-8" />
2608
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
2609
+ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
2610
+ {css.map((file) => (
2611
+ <link rel="stylesheet" href={cssHref(file)} />
2612
+ ))}
2613
+ <Fragment set:html={head} />
2614
+ ${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp}${websiteIdProp}${publisherIdProp}${trailingSlashProp}${robotsProp}${headLdIdsProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
2615
+ </head>
2616
+ <body {...bodyAttrs}>
2617
+ ${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
2618
+ ${renderRefs("footer") ? `${renderRefs("footer")}\n` : ""} </body>
2619
+ </html>
1441
2620
  `;
2621
+ return {
2622
+ files,
2623
+ warnings
2624
+ };
1442
2625
  }
1443
2626
  //#endregion
1444
2627
  //#region src/embed.ts
@@ -1467,13 +2650,39 @@ export interface EntryRef {
1467
2650
 
1468
2651
  export class EmbedError extends Error {
1469
2652
  status: number
1470
- constructor(status: number, message: string) {
2653
+ /** The API's machine code (\`data.code\`), when the body carries one — e.g. \`payment_required\`. */
2654
+ code?: string
2655
+ constructor(status: number, message: string, code?: string) {
1471
2656
  super(message)
1472
2657
  this.name = 'EmbedError'
1473
2658
  this.status = status
2659
+ if (code) this.code = code
1474
2660
  }
1475
2661
  }
1476
2662
 
2663
+ /**
2664
+ * The workspace's subscription is inactive (Studio answers 402
2665
+ * \`payment_required\` on the public forms and comments endpoints). Not the
2666
+ * visitor's problem and not transient: no retry helps until the owner updates
2667
+ * billing.
2668
+ */
2669
+ export function isPaymentRequired(error: unknown): boolean {
2670
+ return error instanceof EmbedError && (error.status === 402 || error.code === 'payment_required')
2671
+ }
2672
+
2673
+ /**
2674
+ * Take a widget off the page without a word to the visitor — the message is
2675
+ * written for the workspace owner, who finds it in the console. The rest of
2676
+ * the page is untouched.
2677
+ */
2678
+ export function hideUnavailable(host: HTMLElement, what: 'form' | 'comments'): void {
2679
+ host.innerHTML = ''
2680
+ host.hidden = true
2681
+ // A site stylesheet that sets display on the element would override hidden.
2682
+ host.style.display = 'none'
2683
+ console.debug('[contentrain] ' + what + ' hidden: the workspace subscription is inactive (402 payment_required). The workspace owner needs to update billing in Contentrain Studio.')
2684
+ }
2685
+
1477
2686
  /**
1478
2687
  * Visitor-facing text. The component passes the page's text from the site's
1479
2688
  * ui-strings dictionary (\`data-strings\`); these are the defaults it falls
@@ -1526,14 +2735,16 @@ export function publicUrl(base: string, segments: string[], query?: Record<strin
1526
2735
  async function failure(res: Response): Promise<EmbedError> {
1527
2736
  const text = await res.text().catch(() => '')
1528
2737
  let message = text || 'Request failed'
2738
+ let code: string | undefined
1529
2739
  try {
1530
- const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown }
2740
+ const parsed = JSON.parse(text) as { message?: unknown; statusMessage?: unknown; data?: { code?: unknown } }
1531
2741
  const m = parsed.message ?? parsed.statusMessage
1532
2742
  if (typeof m === 'string' && m) message = m
2743
+ if (typeof parsed.data?.code === 'string') code = parsed.data.code
1533
2744
  } catch {
1534
2745
  // not JSON — the raw text is the message
1535
2746
  }
1536
- return new EmbedError(res.status, message)
2747
+ return new EmbedError(res.status, message, code)
1537
2748
  }
1538
2749
 
1539
2750
  export async function getJson<T>(url: string): Promise<T> {
@@ -2051,6 +3262,7 @@ export async function mountForm(host: HTMLElement): Promise<void> {
2051
3262
  try {
2052
3263
  config = await fetchFormConfig(rt, model)
2053
3264
  } catch (error) {
3265
+ if (isPaymentRequired(error)) return hideUnavailable(host, 'form')
2054
3266
  host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
2055
3267
  return
2056
3268
  }
@@ -2071,6 +3283,7 @@ export async function mountForm(host: HTMLElement): Promise<void> {
2071
3283
  setStatus(form, errorsHtml(result.errors ?? []))
2072
3284
  resetCaptcha(form)
2073
3285
  } catch (error) {
3286
+ if (isPaymentRequired(error)) return hideUnavailable(host, 'form')
2074
3287
  setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
2075
3288
  resetCaptcha(form)
2076
3289
  } finally {
@@ -2089,6 +3302,7 @@ export async function mountComments(host: HTMLElement): Promise<void> {
2089
3302
  try {
2090
3303
  thread = await fetchThread(rt, entry)
2091
3304
  } catch (error) {
3305
+ if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')
2092
3306
  host.innerHTML = '<p class="cr-error">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>'
2093
3307
  return
2094
3308
  }
@@ -2138,6 +3352,9 @@ export async function mountComments(host: HTMLElement): Promise<void> {
2138
3352
  const list = threadEl.querySelector('.cr-comment-list')
2139
3353
  if (list) list.insertAdjacentHTML('beforeend', next.comments.map((c) => commentHtml(c, next.config)).join(''))
2140
3354
  if (!hasMore(next)) more.closest('.cr-more')?.remove()
3355
+ } catch (error) {
3356
+ if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')
3357
+ // Anything else: the button comes back for another try.
2141
3358
  } finally {
2142
3359
  more.disabled = false
2143
3360
  }
@@ -2179,6 +3396,7 @@ export async function mountComments(host: HTMLElement): Promise<void> {
2179
3396
  resetCaptcha(form)
2180
3397
  setStatus(form, '<p class="cr-success" role="status">' + esc(result.status === 'approved' ? strings.posted : strings.pending) + '</p>')
2181
3398
  } catch (error) {
3399
+ if (isPaymentRequired(error)) return hideUnavailable(host, 'comments')
2182
3400
  setStatus(form, '<p class="cr-error" role="alert">' + esc(error instanceof Error ? error.message : strings.failed) + '</p>')
2183
3401
  resetCaptcha(form)
2184
3402
  } finally {
@@ -2524,143 +3742,377 @@ function chromeComponents(families) {
2524
3742
  };
2525
3743
  }
2526
3744
  //#endregion
2527
- //#region src/alternates.ts
3745
+ //#region src/css.ts
3746
+ const LEADING = /^(\s*(?:@charset[^;]+;|@import[^;]+;|\/\*[\s\S]*?\*\/)\s*)+/;
3747
+ function wrapLegacyCss(path, css) {
3748
+ const rest = css.replace(LEADING, "");
3749
+ if (/@import\s/.test(rest)) return {
3750
+ content: css,
3751
+ warning: `${path}: @import after other rules — left unwrapped (fix the source stylesheet; mid-file imports are ignored by browsers)`
3752
+ };
3753
+ return { content: `${css.slice(0, css.length - rest.length).replace(/@import\s+([^;]+);/g, (full, spec) => /\blayer\s*\(/.test(spec) ? full : `@import ${spec.trim()} layer(${LEGACY_CSS_LAYER});`)}@layer ${LEGACY_CSS_LAYER} {\n${rest}\n}\n` };
3754
+ }
3755
+ //#endregion
3756
+ //#region src/redirects.ts
3757
+ /** The statuses written to `astro.config`; any other is a manual rule. */
3758
+ const REDIRECT_STATUSES = [
3759
+ 301,
3760
+ 302,
3761
+ 307,
3762
+ 308
3763
+ ];
3764
+ /** Whitespace, a control character or DEL anywhere in the string. */
3765
+ const hasControlOrSpace = (value) => [...value].some((char) => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127);
3766
+ /** `/old/` and `/old` are one address in the directory build format. */
3767
+ function addressKey$1(path) {
3768
+ return path === "/" ? "/" : `${path.replace(/\/+$/, "")}/`;
3769
+ }
2528
3770
  /**
2529
- * The path Astro builds for a route pattern and a set of parameters, in the
2530
- * directory format the scaffold configures (`/en/hello/`). Null when a
2531
- * parameter has no value — that page has no address to name.
3771
+ * Every address the emitted site builds a page at, as `addressKey`s, with the
3772
+ * route that owns it. Single and collection routes build one page per entry,
3773
+ * query routes one per result set, and a route without parameters one page.
2532
3774
  */
2533
- function entryPath(pattern, params) {
2534
- const segments = pattern.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
2535
- if (!segments.length) return "/";
2536
- const out = [];
2537
- for (const segment of segments) {
2538
- if (!segment.startsWith(":")) {
2539
- out.push(segment);
3775
+ function builtAddresses(routes, content) {
3776
+ const out = /* @__PURE__ */ new Map();
3777
+ const put = (path, routeId) => {
3778
+ if (path !== null && !out.has(addressKey$1(path))) out.set(addressKey$1(path), routeId);
3779
+ };
3780
+ for (const route of routes) {
3781
+ const pagePath = patternToPagePath(route.pattern);
3782
+ if (!pagePath) continue;
3783
+ if (!pagePath.includes("[")) {
3784
+ put(entryPath(route.pattern, {}), route.id);
2540
3785
  continue;
2541
3786
  }
2542
- const value = params[segment.slice(1).replace(/\*$/, "")]?.replace(/^\/+|\/+$/g, "");
2543
- if (!value) return null;
2544
- out.push(value);
3787
+ if (route.kind === "single" || route.collection !== void 0) for (const post of collectionItems(content, route.collection ?? "posts")) put(entryPath(route.pattern, {
3788
+ ...post.params,
3789
+ slug: post.slug
3790
+ }), route.id);
3791
+ if (route.query) for (const page of content.queries?.[route.query] ?? []) put(entryPath(route.pattern, page.params), route.id);
3792
+ }
3793
+ return out;
3794
+ }
3795
+ /** The decoded path for a literal Astro redirect key, or why `from` cannot be one. */
3796
+ function checkFrom(from) {
3797
+ if (!from.startsWith("/") || from.startsWith("//")) return { reason: "from is not a site-root path" };
3798
+ if (/[?#]/.test(from)) return { reason: "from has a query string or fragment — a static redirect matches the path only" };
3799
+ let path;
3800
+ try {
3801
+ path = decodeURI(from);
3802
+ } catch {
3803
+ return { reason: "from is not a valid percent-encoded path" };
3804
+ }
3805
+ if (hasControlOrSpace(path) || path.includes("\\")) return { reason: "from contains whitespace, control characters or a backslash" };
3806
+ if (/[[\]]/.test(path)) return { reason: "from contains [ or ], which Astro reads as a route parameter" };
3807
+ const segments = path.split("/").filter(Boolean);
3808
+ if (segments.some((s) => s === "." || s === "..")) return { reason: "from contains a . or .. segment" };
3809
+ if (/\.[a-z0-9]{1,5}$/i.test(segments.at(-1) ?? "")) return { reason: "from ends in a file name — the static build serves it as a directory, not at this address" };
3810
+ return { path };
3811
+ }
3812
+ function checkTo(to) {
3813
+ if (hasControlOrSpace(to)) return false;
3814
+ if (to.startsWith("/")) return !to.startsWith("//");
3815
+ try {
3816
+ const url = new URL(to);
3817
+ return url.protocol === "https:" || url.protocol === "http:";
3818
+ } catch {
3819
+ return false;
2545
3820
  }
2546
- return `/${out.join("/")}/`;
2547
3821
  }
2548
- const byLangTag = (a, b) => a.lang < b.lang ? -1 : a.lang > b.lang ? 1 : 0;
2549
3822
  /**
2550
- * Attach `alternates` to every entry that has translations on the site.
2551
- *
2552
- * The language of a page is the one its `<html lang>` gets: the post's own
2553
- * locale, else its route's, else the site's. A site with one language has no
2554
- * alternates and nothing is attached.
3823
+ * Split the rules into what `astro.config` serves and what the host has to.
3824
+ * `built` is `builtAddresses` of the emitted routes.
2555
3825
  */
2556
- function withAlternates(routes, content, siteLocale) {
2557
- const warnings = [];
2558
- const placements = /* @__PURE__ */ new Map();
2559
- for (const route of routes) {
2560
- if (route.kind !== "single" && route.collection === void 0) continue;
2561
- const pagePath = patternToPagePath(route.pattern);
2562
- if (!pagePath) continue;
2563
- const collection = route.collection ?? "posts";
2564
- const posts = collectionItems(content, collection);
2565
- if (!pagePath.includes("[") && posts.length !== 1) continue;
2566
- for (const post of posts) {
2567
- const list = placements.get(post) ?? [];
2568
- list.push({
2569
- lang: post.locale ?? route.locale ?? siteLocale,
2570
- path: entryPath(route.pattern, {
2571
- ...post.params,
2572
- slug: post.slug
2573
- }),
2574
- key: post.entry ? JSON.stringify([post.entry.model_id, post.entry.entry_id]) : void 0,
2575
- collection
2576
- });
2577
- placements.set(post, list);
3826
+ function planRedirects(redirects, built) {
3827
+ const config = {};
3828
+ const written = [];
3829
+ const manual = [];
3830
+ const claimed = /* @__PURE__ */ new Set();
3831
+ const builtLower = new Map([...built].map(([address, route]) => [address.toLowerCase(), [address, route]]));
3832
+ for (const redirect of redirects) {
3833
+ const skip = (reason) => manual.push({
3834
+ redirect,
3835
+ reason
3836
+ });
3837
+ if (redirect.regex) {
3838
+ skip("from is a regular expression");
3839
+ continue;
2578
3840
  }
2579
- }
2580
- if (new Set([...placements.values()].flat().map((p) => p.lang)).size < 2) return {
2581
- content,
2582
- warnings
2583
- };
2584
- const groups = /* @__PURE__ */ new Map();
2585
- const unkeyed = /* @__PURE__ */ new Map();
2586
- const ambiguous = /* @__PURE__ */ new Map();
2587
- for (const [post, list] of placements) {
2588
- const first = list[0];
2589
- if (list.length > 1 || first.path === null) {
2590
- ambiguous.set(first.collection, (ambiguous.get(first.collection) ?? 0) + 1);
3841
+ if (redirect.match !== void 0 && redirect.match !== "url") {
3842
+ skip(`match "${redirect.match}" is a pattern, not one address`);
2591
3843
  continue;
2592
3844
  }
2593
- if (!first.key) {
2594
- unkeyed.set(first.collection, (unkeyed.get(first.collection) ?? 0) + 1);
3845
+ const status = redirect.status ?? 301;
3846
+ if (!REDIRECT_STATUSES.includes(status)) {
3847
+ skip(`status ${status} is not one of ${REDIRECT_STATUSES.join("/")}`);
2595
3848
  continue;
2596
3849
  }
2597
- const group = groups.get(first.key) ?? [];
2598
- group.push({
2599
- post,
2600
- lang: first.lang,
2601
- path: first.path
2602
- });
2603
- groups.set(first.key, group);
2604
- }
2605
- for (const [collection, count] of unkeyed) warnings.push(`collection ${collection}: ${count} posts carry no entry address — hreflang cannot link them to their translations`);
2606
- for (const [collection, count] of ambiguous) warnings.push(`collection ${collection}: ${count} posts have no single address (served by more than one route, or a route parameter without a value) — no hreflang on those pages`);
2607
- const alternatesOf = /* @__PURE__ */ new Map();
2608
- for (const group of groups.values()) {
2609
- if (group.length < 2) continue;
2610
- const byLang = /* @__PURE__ */ new Map();
2611
- let conflict = false;
2612
- for (const member of group) {
2613
- const seen = byLang.get(member.lang);
2614
- if (seen !== void 0 && seen !== member.path) conflict = true;
2615
- byLang.set(member.lang, member.path);
3850
+ const from = checkFrom(redirect.from);
3851
+ if ("reason" in from) {
3852
+ skip(from.reason);
3853
+ continue;
2616
3854
  }
2617
- const entry = group[0].post.entry;
2618
- if (conflict) {
2619
- warnings.push(`entry ${entry.model_id}/${entry.entry_id}: two pages claim the same language — no hreflang for this entry`);
3855
+ if (!checkTo(redirect.to)) {
3856
+ skip("to is not a site-root path or an http(s) URL");
2620
3857
  continue;
2621
3858
  }
2622
- if (byLang.size < 2) continue;
2623
- const alternates = [...byLang].map(([lang, path]) => ({
2624
- lang,
2625
- path
2626
- })).toSorted(byLangTag);
2627
- const fallback = byLang.get(siteLocale);
2628
- if (fallback !== void 0) alternates.push({
2629
- lang: "x-default",
2630
- path: fallback
2631
- });
2632
- for (const member of group) alternatesOf.set(member.post, alternates);
3859
+ const key = addressKey$1(from.path);
3860
+ const owner = built.get(key);
3861
+ if (owner !== void 0) {
3862
+ skip(`the migrated site builds a page at ${key} (route ${owner}) — the page is kept`);
3863
+ continue;
3864
+ }
3865
+ const caseTwin = builtLower.get(key.toLowerCase());
3866
+ if (caseTwin) {
3867
+ skip(`from differs only in letter case from the page at ${caseTwin[0]} (route ${caseTwin[1]}) — a case-insensitive host or file system would serve the redirect over it; the page is kept`);
3868
+ continue;
3869
+ }
3870
+ if (claimed.has(key)) {
3871
+ skip(`another rule already redirects ${key}`);
3872
+ continue;
3873
+ }
3874
+ if (!/^https?:/.test(redirect.to)) {
3875
+ let target;
3876
+ try {
3877
+ target = decodeURI(redirect.to.replace(/[?#].*$/, ""));
3878
+ } catch {
3879
+ target = redirect.to;
3880
+ }
3881
+ if (addressKey$1(target) === key) {
3882
+ skip("to is the same address as from");
3883
+ continue;
3884
+ }
3885
+ }
3886
+ claimed.add(key);
3887
+ config[from.path] = {
3888
+ status,
3889
+ destination: redirect.to
3890
+ };
3891
+ written.push(redirect);
2633
3892
  }
2634
- if (!alternatesOf.size) return {
2635
- content,
2636
- warnings
3893
+ return {
3894
+ config: Object.fromEntries(Object.entries(config).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
3895
+ written,
3896
+ manual
2637
3897
  };
2638
- const mark = (posts) => posts.map((post) => {
2639
- const alternates = alternatesOf.get(post);
2640
- return alternates ? {
2641
- ...post,
2642
- alternates
2643
- } : post;
2644
- });
3898
+ }
3899
+ /** The `redirects` line for astro.config.mjs, or null when there is nothing to write. */
3900
+ function astroRedirectsConfig(config) {
3901
+ const entries = Object.entries(config);
3902
+ if (!entries.length) return null;
3903
+ return [
3904
+ ` redirects: {`,
3905
+ ...entries.map(([from, rule]) => ` ${JSON.stringify(from)}: { status: ${rule.status}, destination: ${JSON.stringify(rule.destination)} },`),
3906
+ ` },`
3907
+ ].join("\n");
3908
+ }
3909
+ /**
3910
+ * Cloudflare Pages takes 2,000 static rules, Vercel 2,048 redirects; each
3911
+ * rule is written twice (with and without its trailing slash).
3912
+ */
3913
+ const HOST_RULE_LIMIT = 2e3;
3914
+ /** Netlify reads `:name` and `*` in a rule as placeholders. */
3915
+ const HOST_PATTERN_CHAR = /(?:^|\/):|\*/;
3916
+ /** path-to-regexp (vercel.json `source`): escape what it would read as syntax. */
3917
+ const escapePathPattern = (s) => s.replace(/[:()*+?{}\\]/g, (c) => `\\${c}`);
3918
+ /** A path as a host matches it: percent-encoded, with and without the trailing slash. */
3919
+ function hostSources(path) {
3920
+ const encoded = encodeURI(path);
3921
+ if (encoded === "/") return ["/"];
3922
+ const bare = encoded.replace(/\/+$/, "");
3923
+ return [bare, `${bare}/`];
3924
+ }
3925
+ /**
3926
+ * Real HTTP redirects for the host the site deploys to. A static Astro build
3927
+ * serves `redirects` as meta-refresh pages; the status code only exists if the
3928
+ * host answers the request itself, from its own redirect file. The
3929
+ * meta-refresh pages stay as the fallback for a host without one.
3930
+ *
3931
+ * - `netlify` — `public/_redirects`, forced (`301!`): the build writes a page
3932
+ * at every redirected path, and Netlify serves an existing file before an
3933
+ * unforced rule.
3934
+ * - `cloudflare` — `public/_redirects`, unforced: Cloudflare Pages has no `!`
3935
+ * and applies its rules before static assets.
3936
+ * - `vercel` — `vercel.json` `redirects`, which Vercel applies before the
3937
+ * filesystem.
3938
+ *
3939
+ * Without a host both the Netlify file and `vercel.json` are written; a site
3940
+ * on Cloudflare Pages should name its host.
3941
+ */
3942
+ function hostRedirectFiles(config, host) {
3943
+ const entries = Object.entries(config);
3944
+ const skipped = entries.filter(([from]) => HOST_PATTERN_CHAR.test(from)).map(([from]) => from);
3945
+ const rules = entries.filter(([from]) => !skipped.includes(from));
3946
+ if (!rules.length) return {
3947
+ files: {},
3948
+ written: [],
3949
+ skipped,
3950
+ over_limit: []
3951
+ };
3952
+ const limited = rules.slice(0, Math.floor(HOST_RULE_LIMIT / 2));
3953
+ const overLimit = host === "netlify" ? [] : rules.slice(limited.length).map(([from]) => from);
3954
+ const files = {};
3955
+ const written = [];
3956
+ const redirectsFile = (force, list) => [
3957
+ "# Emitted by @contentrain/emitter-astro — the source site's redirects, as real HTTP redirects.",
3958
+ ...list.flatMap(([from, rule]) => hostSources(from).map((source) => `${source} ${rule.destination} ${rule.status}${force ? "!" : ""}`)),
3959
+ ""
3960
+ ].join("\n");
3961
+ if (host === void 0 || host === "netlify") {
3962
+ files["public/_redirects"] = redirectsFile(true, rules);
3963
+ written.push("public/_redirects (netlify)");
3964
+ }
3965
+ if (host === "cloudflare") {
3966
+ files["public/_redirects"] = redirectsFile(false, limited);
3967
+ written.push("public/_redirects (cloudflare)");
3968
+ }
3969
+ if (host === void 0 || host === "vercel") {
3970
+ const redirects = limited.flatMap(([from, rule]) => hostSources(from).map((source) => ({
3971
+ source: escapePathPattern(source),
3972
+ destination: rule.destination,
3973
+ statusCode: rule.status
3974
+ })));
3975
+ files["vercel.json"] = `${JSON.stringify({ redirects }, null, 2)}\n`;
3976
+ written.push("vercel.json (vercel)");
3977
+ }
2645
3978
  return {
2646
- content: {
2647
- ...content,
2648
- ...content.posts ? { posts: mark(content.posts) } : {},
2649
- ...content.collections ? { collections: Object.fromEntries(Object.entries(content.collections).map(([name, posts]) => [name, mark(posts)])) } : {}
2650
- },
2651
- warnings
3979
+ files,
3980
+ written,
3981
+ skipped,
3982
+ over_limit: overLimit
2652
3983
  };
2653
3984
  }
2654
3985
  //#endregion
2655
- //#region src/css.ts
2656
- const LEADING = /^(\s*(?:@charset[^;]+;|@import[^;]+;|\/\*[\s\S]*?\*\/)\s*)+/;
2657
- function wrapLegacyCss(path, css) {
2658
- const rest = css.replace(LEADING, "");
2659
- if (/@import\s/.test(rest)) return {
2660
- content: css,
2661
- warning: `${path}: @import after other rules — left unwrapped (fix the source stylesheet; mid-file imports are ignored by browsers)`
3986
+ //#region src/seo-entry.ts
3987
+ /**
3988
+ * A plugin's unrendered template, in its own syntax: Yoast `%%title%%`,
3989
+ * SEOPress `%%post_title%%`, Rank Math `%title%` / `%customfield(name)%`,
3990
+ * AIOSEO `#post_title`. Printed as-is it would be a literal
3991
+ * `%%title%% %%sep%%` in the search result, so such a value is dropped and
3992
+ * the page's own falls in. AIOSEO's tags are matched by name, not as any
3993
+ * `#word`: a title can hold a hashtag.
3994
+ */
3995
+ const TEMPLATE_RE = {
3996
+ yoast: /%%[a-z0-9_-]+%%/i,
3997
+ seopress: /%%[a-z0-9_-]+%%/i,
3998
+ rank_math: /%[a-z_]+(?:\([^)]*\))?%/i,
3999
+ aioseo: /#(?:(?:alt|archive|attachment|author|category|current|custom_field|page|parent|post|search|separator|site|tax|taxonomy)_[a-z0-9_-]+|categories|permalink|tagline)\b/i
4000
+ };
4001
+ /**
4002
+ * Whether a string holds a template token. Percent-encoding is decoded first
4003
+ * when it decodes: `%E4%B8%AD` in a CJK URL is text, not Rank Math's `%AD%`.
4004
+ * A real token never decodes (`%ti…` is no escape), so it is tested as it is.
4005
+ */
4006
+ function hasToken(value, template) {
4007
+ let decoded = value;
4008
+ try {
4009
+ decoded = decodeURI(value);
4010
+ } catch {}
4011
+ return template.test(decoded);
4012
+ }
4013
+ const text = (value, template) => {
4014
+ const v = value?.trim();
4015
+ if (!v) return void 0;
4016
+ return template && hasToken(v, template) ? void 0 : v;
4017
+ };
4018
+ const addressKey = (url) => url.trim().replace(/^https?:\/\//i, "//").replace(/\/+(?=[?#]|$)/, "");
4019
+ function social(value, template) {
4020
+ if (!value) return void 0;
4021
+ const out = {};
4022
+ const title = text(value.title, template);
4023
+ const description = text(value.description, template);
4024
+ const image = value.image?.trim();
4025
+ const card = value.card?.trim();
4026
+ if (title) out.title = title;
4027
+ if (description) out.description = description;
4028
+ if (image) out.image = image;
4029
+ if (card) out.card = card;
4030
+ return Object.keys(out).length ? out : void 0;
4031
+ }
4032
+ /** Share-card values: the rendered ones, each missing one from the block. */
4033
+ function merged(rendered, stored) {
4034
+ if (!rendered) return stored;
4035
+ if (!stored) return rendered;
4036
+ return {
4037
+ ...stored,
4038
+ ...rendered
2662
4039
  };
2663
- return { content: `${css.slice(0, css.length - rest.length).replace(/@import\s+([^;]+);/g, (full, spec) => /\blayer\s*\(/.test(spec) ? full : `@import ${spec.trim()} layer(${LEGACY_CSS_LAYER});`)}@layer ${LEGACY_CSS_LAYER} {\n${rest}\n}\n` };
4040
+ }
4041
+ /**
4042
+ * One page's SEO fields from the blocks each plugin holds for it. The serving
4043
+ * plugin's block wins; without one the first plugin (Yoast, Rank Math,
4044
+ * AIOSEO, SEOPress) that has data does. Its values are read, in order, from:
4045
+ *
4046
+ * 1. the block itself when the running plugin rendered it (`resolved`);
4047
+ * 2. `rendered` — the exporter's own rendering of the plugin's templates, for
4048
+ * a plugin that is not the one serving the head. Final text: a variable it
4049
+ * could not render is already out of the string, and only listed in
4050
+ * `unresolved` for a person to read;
4051
+ * 3. the stored values, with the plugin's unrendered templates dropped.
4052
+ *
4053
+ * 2 and 3 are taken field by field: a value the rendering does not carry
4054
+ * falls back to the block's stored literal.
4055
+ *
4056
+ * Whatever the source, a string that still holds a template token in the
4057
+ * plugin's syntax is dropped: a token is never printed. The same holds for
4058
+ * the JSON-LD: a resolved block's graph is the plugin's rendering; any other
4059
+ * block's graph — the exporter's rendered nodes, else its own top-level graph
4060
+ * — is read node by node, and a node still holding a token is left out.
4061
+ *
4062
+ * Robots come from `robots_served` — what the page actually carried after
4063
+ * WordPress and the plugin reconciled their settings — then `rendered.robots`,
4064
+ * then the plugin's own `robots`.
4065
+ */
4066
+ function seoFromRawEntry(blocks, options = {}) {
4067
+ if (!blocks) return {};
4068
+ const provider = [...options.serving && options.serving !== "wordpress-core" ? [options.serving] : [], ...SEO_PROVIDERS].find((p) => blocks[p] !== void 0);
4069
+ if (!provider) return {};
4070
+ const entry = blocks[provider];
4071
+ const template = entry.resolved === true ? void 0 : TEMPLATE_RE[provider];
4072
+ const rendered = entry.resolved === true ? void 0 : entry.rendered;
4073
+ const out = {};
4074
+ const title = text(rendered?.title, template) ?? text(entry.title, template);
4075
+ if (title) out.seo_title = title;
4076
+ const description = text(rendered?.description, template) ?? text(entry.description, template);
4077
+ if (description) out.description = description;
4078
+ const canonical = (rendered?.canonical ?? entry.canonical)?.trim();
4079
+ if (canonical && !(options.url && addressKey(canonical) === addressKey(options.url))) out.canonical = canonical;
4080
+ const served = entry.robots_served?.map((d) => d.trim().toLowerCase());
4081
+ const robots = rendered?.robots ?? entry.robots;
4082
+ const noindex = served ? served.includes("noindex") : robots?.index === "noindex";
4083
+ const nofollow = served ? served.includes("nofollow") : robots?.follow === "nofollow";
4084
+ if (noindex) out.noindex = true;
4085
+ if (nofollow) out.nofollow = true;
4086
+ const og = merged(social(rendered?.open_graph, template), social(entry.open_graph, template));
4087
+ if (og) out.open_graph = og;
4088
+ const tw = merged(social(rendered?.twitter, template), social(entry.twitter, template));
4089
+ if (tw) out.twitter = tw;
4090
+ if (entry.resolved === true) {
4091
+ const graph = entry.schema?.graph;
4092
+ if (graph && typeof graph === "object") out.schema = graph;
4093
+ } else if (template) {
4094
+ const top = entry.schema?.graph;
4095
+ const nodes = (Array.isArray(rendered?.schema?.graph) ? rendered.schema.graph : ldNodes(top)).filter((node) => !holdsToken(node, template));
4096
+ if (nodes.length) out.schema = {
4097
+ "@context": "https://schema.org",
4098
+ "@graph": nodes
4099
+ };
4100
+ }
4101
+ return out;
4102
+ }
4103
+ /** A JSON-LD value's nodes: a @graph object's graph, a node array, or one node. */
4104
+ function ldNodes(value) {
4105
+ if (!value || typeof value !== "object") return [];
4106
+ if (Array.isArray(value)) return value;
4107
+ const graph = value["@graph"];
4108
+ return Array.isArray(graph) ? graph : [value];
4109
+ }
4110
+ /** Whether any string anywhere in a JSON value holds a template token. */
4111
+ function holdsToken(value, template) {
4112
+ if (typeof value === "string") return hasToken(value, template);
4113
+ if (Array.isArray(value)) return value.some((v) => holdsToken(v, template));
4114
+ if (value && typeof value === "object") return Object.values(value).some((v) => holdsToken(v, template));
4115
+ return false;
2664
4116
  }
2665
4117
  //#endregion
2666
4118
  //#region src/index.ts
@@ -2671,6 +4123,31 @@ function wrapLegacyCss(path, css) {
2671
4123
  function badTrail(trail) {
2672
4124
  return trail !== void 0 && !(trail.length > 0 && trail.every((c) => typeof c?.name === "string" && c.name.trim() !== "" && typeof c.path === "string" && !c.path.includes(String.fromCharCode(92)) && ![...c.path].some((char) => char.charCodeAt(0) <= 32) && /^\/(?!\/)/.test(c.path)));
2673
4125
  }
4126
+ /**
4127
+ * A source JSON-LD value the Seo component cannot print (see
4128
+ * `sourceStructuredData`): not an object, or no node in it names a type.
4129
+ */
4130
+ function badSchema(schema) {
4131
+ if (schema === void 0) return false;
4132
+ if (!schema || typeof schema !== "object") return true;
4133
+ const graph = schema["@graph"];
4134
+ return !(Array.isArray(schema) ? schema : Array.isArray(graph) ? graph : [schema]).some((n) => {
4135
+ const type = n && typeof n === "object" && !Array.isArray(n) ? n["@type"] : void 0;
4136
+ return (Array.isArray(type) ? type : [type]).some((t) => typeof t === "string");
4137
+ });
4138
+ }
4139
+ /** Redirect config sorted by `from`, the order astro.config and the host files list it in. */
4140
+ function sortedConfig(config) {
4141
+ return Object.fromEntries(Object.entries(config).toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
4142
+ }
4143
+ /** A redirect's from as its config key: percent-encoding decoded, as planRedirects writes it. */
4144
+ function decodedPath(from) {
4145
+ try {
4146
+ return decodeURI(from);
4147
+ } catch {
4148
+ return from;
4149
+ }
4150
+ }
2674
4151
  function emitAstroProject(input) {
2675
4152
  const { ir } = input;
2676
4153
  const warnings = [];
@@ -2684,8 +4161,70 @@ function emitAstroProject(input) {
2684
4161
  files[path] = content;
2685
4162
  }
2686
4163
  };
2687
- add(scaffoldFiles(ir, input.options ?? {}));
4164
+ const siteLang = ir.site.locales?.[0] ?? "en";
4165
+ const linkable = ir.site.url ? linkSources(ir.routes, siteLang) : [];
4166
+ const feedSource = input.options?.feed !== false ? linkable.find((s) => s.collection === DEFAULT_COLLECTION) : void 0;
4167
+ const llmsOn = input.options?.llms !== false && linkable.length > 0;
4168
+ const archiveFeeds = feedSource ? archiveFeedSources(ir.routes, siteLang) : [];
4169
+ const archiveFeedRoutes = new Set(archiveFeeds.map((a) => a.routeId));
4170
+ if (!ir.site.url && (input.options?.feed !== false || input.options?.llms !== false)) warnings.push("site.url is empty — no RSS feed and no llms.txt are built; both name pages by absolute address");
4171
+ const redirectPlan = input.redirects ? planRedirects(input.redirects, builtAddresses(ir.routes, input.content ?? {})) : void 0;
4172
+ if (redirectPlan?.manual.length) warnings.push(`redirects: ${redirectPlan.manual.length} of ${input.redirects.length} rules not written to astro.config — set them up at the host (EmitResult.redirects.manual has each with its reason)`);
2688
4173
  const seo = input.options?.seo !== false;
4174
+ const noindex = noindexPaths(ir.routes, input.content ?? {});
4175
+ const built = builtAddresses(ir.routes, input.content ?? {});
4176
+ const sourceConfig = redirectPlan?.config ?? {};
4177
+ const claimed = new Set(Object.keys(sourceConfig).map((from) => from.replace(/\/+$/, "").toLowerCase()));
4178
+ const feedRules = [];
4179
+ const addFeedRule = (from, to) => {
4180
+ const key = from.replace(/\/+$/, "").toLowerCase();
4181
+ if (claimed.has(key)) return;
4182
+ claimed.add(key);
4183
+ feedRules.push({
4184
+ from,
4185
+ to,
4186
+ status: 301
4187
+ });
4188
+ };
4189
+ if (feedSource) addFeedRule(FEED_REDIRECT_FROM, FEED_PATH);
4190
+ for (const source of archiveFeeds) for (const page of input.content?.queries?.[source.query] ?? []) {
4191
+ const address = entryPath(source.pattern, page.params);
4192
+ if (address) addFeedRule(`${address}feed/`, `${address}feed.xml`);
4193
+ }
4194
+ const feedPlan = planRedirects(feedRules, built);
4195
+ if (feedPlan.manual.length) warnings.push(`feed: ${feedPlan.manual.length} feed redirects not written — ${feedPlan.manual.map((m) => `${m.redirect.from} (${m.reason})`).join("; ")}`);
4196
+ const redirectConfig = {
4197
+ ...sourceConfig,
4198
+ ...feedPlan.config
4199
+ };
4200
+ const hasRedirects = Object.keys(redirectConfig).length > 0;
4201
+ add(scaffoldFiles(ir, input.options ?? {}, noindex, hasRedirects ? astroRedirectsConfig(sortedConfig(redirectConfig)) : null, input.runtime));
4202
+ const feedOrder = new Map(feedRules.map((rule, index) => [decodedPath(rule.from), index]));
4203
+ const feedKeys = Object.keys(feedPlan.config).toSorted((a, b) => (feedOrder.get(a) ?? 0) - (feedOrder.get(b) ?? 0));
4204
+ const hostOrder = {
4205
+ ...sortedConfig(sourceConfig),
4206
+ ...Object.fromEntries(feedKeys.map((key) => [key, feedPlan.config[key]]))
4207
+ };
4208
+ const hostRedirects = hasRedirects ? hostRedirectFiles(hostOrder, input.options?.redirectHost) : void 0;
4209
+ if (hostRedirects) {
4210
+ add(hostRedirects.files);
4211
+ if (hostRedirects.over_limit.length) {
4212
+ const limitedFiles = hostRedirects.written.filter((f) => !f.includes("(netlify)")).join(", ");
4213
+ const feedOver = hostRedirects.over_limit.filter((from) => from in feedPlan.config).length;
4214
+ const which = feedOver ? ` (${feedOver} of them feed redirects — /feed/ and archive feeds come after the site's own rules)` : "";
4215
+ warnings.push(`redirects: ${hostRedirects.over_limit.length} rules not written to ${limitedFiles}${which} — ${HOST_RULE_LIMIT / 2} rules (each path with and without its slash) fill Cloudflare Pages' 2,000 static and Vercel's 2,048 redirect limit. They are served by the meta-refresh fallback only; move them to the host's dynamic rules for a real status`);
4216
+ }
4217
+ if (hostRedirects.skipped.length) warnings.push(`redirects: ${hostRedirects.skipped.length} rules contain ":" or "*", which host redirect files read as patterns — served by the meta-refresh fallback only: ${hostRedirects.skipped.join(", ")}`);
4218
+ }
4219
+ if (input.options?.trailingSlash === false && (input.options.redirectHost ?? "vercel") === "vercel") {
4220
+ const vercel = files["vercel.json"] ? JSON.parse(files["vercel.json"]) : {};
4221
+ files["vercel.json"] = `${JSON.stringify({
4222
+ cleanUrls: true,
4223
+ trailingSlash: false,
4224
+ ...vercel
4225
+ }, null, 2)}\n`;
4226
+ }
4227
+ if (noindex.length && !seo) warnings.push(`${noindex.length} noindex pages: options.seo is false, so no robots meta is emitted — the producer's head must carry it (they are still left out of the sitemap)`);
2689
4228
  if (seo) {
2690
4229
  add({ "src/components/Seo.astro": SEO_COMPONENT });
2691
4230
  if (!ir.site.url) warnings.push("site.url is empty — canonical links and absolute og:url/og:image are omitted; set it so search engines and share cards resolve");
@@ -2724,7 +4263,10 @@ function emitAstroProject(input) {
2724
4263
  const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions, bodyMarkersByFamily.get(family.id) ?? [], {
2725
4264
  seo,
2726
4265
  siteName: ir.site.title,
2727
- boundQueries
4266
+ boundQueries,
4267
+ images: imagesEnabled(input.options ?? {}),
4268
+ feedSite: feedSource ? ir.site.url : void 0,
4269
+ trailingSlash: input.options?.trailingSlash
2728
4270
  });
2729
4271
  add(fam.files);
2730
4272
  warnings.push(...fam.warnings);
@@ -2746,10 +4288,14 @@ function emitAstroProject(input) {
2746
4288
  for (const [name, posts] of trailOwners) {
2747
4289
  const bad = (posts ?? []).filter((p) => badTrail(p.breadcrumbs)).length;
2748
4290
  if (bad) warnings.push(`collection ${name}: ${bad} breadcrumb trails dropped — every crumb needs a name and a site-root-relative path`);
4291
+ const badLd = (posts ?? []).filter((p) => badSchema(p.schema)).length;
4292
+ if (badLd) warnings.push(`collection ${name}: ${badLd} schema values are not JSON-LD objects — those pages print the generated structured data instead`);
2749
4293
  }
2750
4294
  for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) {
2751
4295
  const bad = queryPages.filter((qp) => badTrail(qp.breadcrumbs)).length;
2752
4296
  if (bad) warnings.push(`query ${queryId}: ${bad} breadcrumb trails dropped — every crumb needs a name and a site-root-relative path`);
4297
+ const badLd = queryPages.filter((qp) => badSchema(qp.schema)).length;
4298
+ if (badLd) warnings.push(`query ${queryId}: ${badLd} schema values are not JSON-LD objects — those pages print the generated structured data instead`);
2753
4299
  }
2754
4300
  let routeContent = input.content ?? {};
2755
4301
  if (seo) {
@@ -2771,7 +4317,7 @@ function emitAstroProject(input) {
2771
4317
  const pageOwner = /* @__PURE__ */ new Map();
2772
4318
  const collisions = [];
2773
4319
  for (const route of ir.routes) {
2774
- const result = routeFiles(route, familiesById.get(route.family), routeContent, lang, seo);
4320
+ const result = routeFiles(route, familiesById.get(route.family), routeContent, lang, seo, archiveFeedRoutes.has(route.id));
2775
4321
  for (const path of sharedPages.keys()) delete result.files[path];
2776
4322
  for (const path of Object.keys(result.files)) {
2777
4323
  if (!path.startsWith("src/pages/")) continue;
@@ -2813,9 +4359,32 @@ function emitAstroProject(input) {
2813
4359
  if (unbound) warnings.push(`collection ${name}: ${unbound} of ${posts.length} posts carry no entry address — the comments component renders nothing on those pages`);
2814
4360
  }
2815
4361
  }
4362
+ const feedInput = {
4363
+ ir,
4364
+ siteLocale: lang,
4365
+ description: input.options?.siteDescription,
4366
+ trailingSlash: input.options?.trailingSlash !== false
4367
+ };
4368
+ const written = (source) => files[`src/data/${source.collection}.json`] !== void 0;
4369
+ if (feedSource) if (written(feedSource)) add({ "src/pages/feed.xml.ts": feedEndpoint(feedSource, feedInput) });
4370
+ else warnings.push(`feed: no posts data file was written, so no feed is built — the head's feed link points at ${FEED_PATH}, which 404s`);
4371
+ for (const source of archiveFeeds) {
4372
+ const file = archiveFeedFile(source.pattern);
4373
+ if (file && feedSource && files[`src/data/queries/${source.query}.json`] !== void 0) add({ [file]: archiveFeedEndpoint(source, feedSource.pattern, feedInput) });
4374
+ }
4375
+ if (llmsOn) {
4376
+ const listed = linkable.filter(written);
4377
+ if (listed.length) add({ "src/pages/llms.txt.ts": llmsEndpoint(listed, feedInput) });
4378
+ }
2816
4379
  return {
2817
4380
  files,
2818
- warnings
4381
+ warnings,
4382
+ ...redirectPlan ? { redirects: {
4383
+ written: redirectPlan.written,
4384
+ manual: redirectPlan.manual,
4385
+ host_files: hostRedirects?.written ?? [],
4386
+ host_over_limit: hostRedirects?.over_limit ?? []
4387
+ } } : {}
2819
4388
  };
2820
4389
  }
2821
4390
  /**
@@ -2838,6 +4407,6 @@ async function writeEmit(result, dir) {
2838
4407
  await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
2839
4408
  }
2840
4409
  //#endregion
2841
- export { EMBED_TS, RUNTIME_IMPLEMENTED, SEO_COMPONENT, UI_STRINGS_DIR, UI_STRINGS_MODEL, UI_STRING_DEFAULTS, balanceWarning, bodySeoLeaks, checkBalance, componentMarkers, emitAstroProject, entryPath, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, stripSeoTags, websiteIdOf, withAlternates, wrapLegacyCss, writeEmit };
4410
+ export { EMBED_TS, FEED_ITEMS, FEED_PATH, LLMS_LINKS, REDIRECT_STATUSES, RUNTIME_IMPLEMENTED, SEO_COMPONENT, UI_STRINGS_DIR, UI_STRINGS_MODEL, UI_STRING_DEFAULTS, astroRedirectsConfig, balanceWarning, bodySeoLeaks, builtAddresses, checkBalance, componentMarkers, emitAstroProject, entryPath, hostRedirectFiles, isRuntimeImplemented, linkSources, noindexPaths, pascalCase, patternToPagePath, planRedirects, publisherIdOf, rewriteFeedLinks, seoFromRawEntry, stableJson, stripSeoTags, websiteIdOf, withAlternates, wrapLegacyCss, writeEmit };
2842
4411
 
2843
4412
  //# sourceMappingURL=index.mjs.map