@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/README.md +105 -4
- package/dist/index.d.mts +321 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1937 -368
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
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&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, '"')
|
|
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, '&').replace(/"/g, '"')
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
{
|
|
1087
|
-
{
|
|
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
|
-
{
|
|
1090
|
-
{
|
|
1091
|
-
{
|
|
1092
|
-
{
|
|
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
|
-
{
|
|
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={
|
|
1100
|
-
{
|
|
1101
|
-
{
|
|
1102
|
-
{
|
|
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 {
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
*
|
|
2530
|
-
*
|
|
2531
|
-
*
|
|
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
|
|
2534
|
-
const
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
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
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
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
|
-
*
|
|
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
|
|
2557
|
-
const
|
|
2558
|
-
const
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
const
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2594
|
-
|
|
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
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
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
|
-
|
|
2618
|
-
|
|
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
|
-
|
|
2623
|
-
const
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
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
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
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
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
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
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
},
|
|
2651
|
-
warnings
|
|
3979
|
+
files,
|
|
3980
|
+
written,
|
|
3981
|
+
skipped,
|
|
3982
|
+
over_limit: overLimit
|
|
2652
3983
|
};
|
|
2653
3984
|
}
|
|
2654
3985
|
//#endregion
|
|
2655
|
-
//#region src/
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|