@contentrain/emitter-astro 0.12.1 → 0.14.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 +50 -6
- package/dist/index.d.mts +137 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +861 -68
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, LEGACY_CSS_LAYER } from "@contentrain/types";
|
|
1
|
+
import { CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, DICTIONARY_TITLE_FIELD, LEGACY_CSS_LAYER } 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";
|
|
@@ -47,6 +47,7 @@ function stableJson(value) {
|
|
|
47
47
|
function scaffoldFiles(ir, options) {
|
|
48
48
|
const tailwind = options.tailwind !== false;
|
|
49
49
|
const split = ir.viewport_strategy === "split";
|
|
50
|
+
const sitemap = options.sitemap !== false && Boolean(ir.site.url);
|
|
50
51
|
const files = {};
|
|
51
52
|
files["package.json"] = stableJson({
|
|
52
53
|
name: options.projectName ?? "migrated-site",
|
|
@@ -66,7 +67,8 @@ function scaffoldFiles(ir, options) {
|
|
|
66
67
|
...tailwind ? {
|
|
67
68
|
tailwindcss: "^4.0.0",
|
|
68
69
|
"@tailwindcss/vite": "^4.0.0"
|
|
69
|
-
} : {}
|
|
70
|
+
} : {},
|
|
71
|
+
...sitemap ? { "@astrojs/sitemap": "^3.7.0" } : {}
|
|
70
72
|
},
|
|
71
73
|
devDependencies: {
|
|
72
74
|
"@astrojs/check": "^0.9.0",
|
|
@@ -76,10 +78,12 @@ function scaffoldFiles(ir, options) {
|
|
|
76
78
|
files["astro.config.mjs"] = [
|
|
77
79
|
`import { defineConfig } from 'astro/config'`,
|
|
78
80
|
...tailwind ? [`import tailwindcss from '@tailwindcss/vite'`] : [],
|
|
81
|
+
...sitemap ? [`import sitemap from '@astrojs/sitemap'`] : [],
|
|
79
82
|
``,
|
|
80
83
|
`export default defineConfig({`,
|
|
81
|
-
` site: ${JSON.stringify(ir.site.url)}
|
|
84
|
+
...ir.site.url ? [` site: ${JSON.stringify(ir.site.url)},`] : [],
|
|
82
85
|
` build: { format: 'directory' },`,
|
|
86
|
+
...sitemap ? [` integrations: [sitemap()],`] : [],
|
|
83
87
|
...tailwind ? [` vite: { plugins: [tailwindcss()] },`] : [],
|
|
84
88
|
`})`,
|
|
85
89
|
``
|
|
@@ -94,9 +98,34 @@ function scaffoldFiles(ir, options) {
|
|
|
94
98
|
]
|
|
95
99
|
}, null, 2)}\n`;
|
|
96
100
|
if (tailwind) files["src/styles/modern.css"] = modernCss(ir.tokens);
|
|
101
|
+
if (options.sitemap !== false) files["public/robots.txt"] = robotsTxt(ir.site.url);
|
|
97
102
|
files["src/lib/fill.ts"] = FILL_TS;
|
|
98
103
|
return files;
|
|
99
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* `robots.txt` allowing crawlers and naming the sitemap.
|
|
107
|
+
*
|
|
108
|
+
* The `Sitemap:` line must be an absolute URL — a relative one is invalid and
|
|
109
|
+
* crawlers ignore it. Without a site to make it absolute the line is left out
|
|
110
|
+
* rather than pointed at a build host; the emitter warns about the missing site
|
|
111
|
+
* separately.
|
|
112
|
+
*
|
|
113
|
+
* Nothing is disallowed. A WordPress robots.txt typically keeps crawlers out of
|
|
114
|
+
* `/wp-admin/`; the migrated site has no such path, so carrying the rule over
|
|
115
|
+
* would only restrict a site that no longer needs restricting.
|
|
116
|
+
*
|
|
117
|
+
* The sitemap is resolved under the site's own path, not the host root: a site
|
|
118
|
+
* at `https://example.com/blog` serves its build output, sitemap included, under
|
|
119
|
+
* `/blog/`.
|
|
120
|
+
*/
|
|
121
|
+
function robotsTxt(siteUrl) {
|
|
122
|
+
const lines = ["User-agent: *", "Allow: /"];
|
|
123
|
+
if (siteUrl) {
|
|
124
|
+
const base = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`;
|
|
125
|
+
lines.push("", `Sitemap: ${new URL("sitemap-index.xml", base).href}`);
|
|
126
|
+
}
|
|
127
|
+
return `${lines.join("\n")}\n`;
|
|
128
|
+
}
|
|
100
129
|
/** Tailwind 4 evolution layer: CSS-first config with the site's extracted tokens. */
|
|
101
130
|
function modernCss(tokens) {
|
|
102
131
|
const lines = [`@import 'tailwindcss';`, ``];
|
|
@@ -266,6 +295,44 @@ export function renderSections<T>(
|
|
|
266
295
|
return out.join('')
|
|
267
296
|
}
|
|
268
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Render a query's results for a region inside a page — a "recent posts" block
|
|
300
|
+
* in an article's chrome, not a list page.
|
|
301
|
+
*
|
|
302
|
+
* A list PAGE maps each result set to a route, so several are expected. A
|
|
303
|
+
* region has no route to distinguish them, so exactly one is expected and
|
|
304
|
+
* anything else is a build error rather than a guess: picking the first would
|
|
305
|
+
* silently render one category's posts under every category.
|
|
306
|
+
*
|
|
307
|
+
* Without item markup the region gets the same plain list a list page does,
|
|
308
|
+
* and the emitter has warned. An empty string here was a region that vanished
|
|
309
|
+
* from every page with nothing anywhere saying so.
|
|
310
|
+
*/
|
|
311
|
+
export function renderQuery(pages: EmittedQueryPage[], id: string): string {
|
|
312
|
+
if (pages.length !== 1) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
'Query "' + id + '" fills a page region, so it must have exactly one result set; got '
|
|
315
|
+
+ pages.length + '. A region has no route parameter to choose between them.',
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
return renderQueryPage(pages[0]!)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* One result set as markup: its sections, its single item template, or — when
|
|
323
|
+
* the producer supplied neither — a plain list of links. The plain list is not
|
|
324
|
+
* fidelity and the emitter warns wherever it will be used; it exists so the
|
|
325
|
+
* items are still there, because a list that renders nothing looks like a list
|
|
326
|
+
* with no posts.
|
|
327
|
+
*/
|
|
328
|
+
export function renderQueryPage(page: EmittedQueryPage): string {
|
|
329
|
+
const sections = page.sections?.length ? page.sections : page.item_template ? [{ template: page.item_template }] : []
|
|
330
|
+
if (sections.length) return renderSections(sections, page.items, postMarks)
|
|
331
|
+
return '<ul class="cr-post-list">'
|
|
332
|
+
+ page.items.map((item) => '<li><a href="/' + item.slug + '/">' + esc(item.title) + '</a></li>').join('')
|
|
333
|
+
+ '</ul>'
|
|
334
|
+
}
|
|
335
|
+
|
|
269
336
|
// ─── SEO helpers ───
|
|
270
337
|
|
|
271
338
|
/**
|
|
@@ -301,6 +368,23 @@ export function absoluteUrl(value: string | undefined, site: URL | undefined): s
|
|
|
301
368
|
}
|
|
302
369
|
}
|
|
303
370
|
|
|
371
|
+
export interface ImageMeta {
|
|
372
|
+
width?: number
|
|
373
|
+
height?: number
|
|
374
|
+
type?: string
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The \`og:image:*\` values worth printing. A width that is not a positive
|
|
379
|
+
* integer, or a type that is not an image MIME type, would be a wrong tag
|
|
380
|
+
* rather than a missing one, so it is dropped.
|
|
381
|
+
*/
|
|
382
|
+
export function imageMetaTags(meta: ImageMeta | undefined): { width?: string; height?: string; type?: string } {
|
|
383
|
+
const size = (n: unknown) => (typeof n === 'number' && Number.isInteger(n) && n > 0 ? String(n) : undefined)
|
|
384
|
+
const type = typeof meta?.type === 'string' && /^image\\/[a-z0-9.+-]+$/i.test(meta.type) ? meta.type.toLowerCase() : undefined
|
|
385
|
+
return { width: size(meta?.width), height: size(meta?.height), type }
|
|
386
|
+
}
|
|
387
|
+
|
|
304
388
|
/**
|
|
305
389
|
* JSON-LD payload for a \`<script type="application/ld+json">\`. \`<\` is escaped
|
|
306
390
|
* so a value containing \`<\/script>\` cannot close the block and inject markup.
|
|
@@ -309,6 +393,102 @@ export function jsonLd(value: unknown): string {
|
|
|
309
393
|
return JSON.stringify(value).replace(/</g, '\\\\u003c')
|
|
310
394
|
}
|
|
311
395
|
|
|
396
|
+
export interface Breadcrumb {
|
|
397
|
+
name: string
|
|
398
|
+
path: string
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A trail every crumb of which can be printed, or undefined. A crumb needs a
|
|
403
|
+
* name and a site-root-relative path; one bad crumb drops the trail, because a
|
|
404
|
+
* breadcrumb with a hole in it states a hierarchy the site does not have.
|
|
405
|
+
*/
|
|
406
|
+
export function validTrail(trail: unknown): Breadcrumb[] | undefined {
|
|
407
|
+
if (!Array.isArray(trail) || !trail.length) return undefined
|
|
408
|
+
const ok = trail.every((c) => c && typeof c === 'object'
|
|
409
|
+
&& typeof (c as Breadcrumb).name === 'string' && (c as Breadcrumb).name.trim() !== ''
|
|
410
|
+
&& typeof (c as Breadcrumb).path === 'string'
|
|
411
|
+
&& !(c as Breadcrumb).path.includes(String.fromCharCode(92))
|
|
412
|
+
&& ![...(c as Breadcrumb).path].some((char) => char.charCodeAt(0) <= 32) && /^\\/(?!\\/)/.test((c as Breadcrumb).path))
|
|
413
|
+
return ok ? (trail as Breadcrumb[]) : undefined
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface PageStructuredDataInput {
|
|
417
|
+
url?: string
|
|
418
|
+
site?: URL
|
|
419
|
+
title: string
|
|
420
|
+
description?: string
|
|
421
|
+
image?: string
|
|
422
|
+
locale?: string
|
|
423
|
+
article: boolean
|
|
424
|
+
pageType?: 'WebPage' | 'CollectionPage'
|
|
425
|
+
publishedAt?: string
|
|
426
|
+
modifiedAt?: string
|
|
427
|
+
author?: string
|
|
428
|
+
siteName?: string
|
|
429
|
+
/** \`@id\` of the site's own WebSite node, when its head declares one. */
|
|
430
|
+
websiteId?: string
|
|
431
|
+
breadcrumbs?: Breadcrumb[]
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The page's structured data as one graph: the page itself (WebPage, or
|
|
436
|
+
* CollectionPage for a list), its breadcrumb trail, and on an entry the
|
|
437
|
+
* Article whose main entity it is — linked by \`@id\` rather than repeated.
|
|
438
|
+
*
|
|
439
|
+
* The page node needs an absolute address to be anything; without \`site\`
|
|
440
|
+
* only an entry's Article remains, as before. \`isPartOf\` points at the site's
|
|
441
|
+
* own WebSite node when the kept head declares one — never at a WebSite this
|
|
442
|
+
* function invents.
|
|
443
|
+
*/
|
|
444
|
+
export function pageStructuredData(input: PageStructuredDataInput): Record<string, unknown> | undefined {
|
|
445
|
+
const graph: Array<Record<string, unknown>> = []
|
|
446
|
+
const { url } = input
|
|
447
|
+
const trail = url ? validTrail(input.breadcrumbs) : undefined
|
|
448
|
+
const crumbs = trail
|
|
449
|
+
? trail.map((c) => ({ name: c.name, item: absoluteUrl(c.path, input.site) }))
|
|
450
|
+
: []
|
|
451
|
+
const breadcrumbId = url && trail && crumbs.every((c) => c.item) ? url + '#breadcrumb' : undefined
|
|
452
|
+
if (url) {
|
|
453
|
+
graph.push({
|
|
454
|
+
'@type': input.pageType ?? 'WebPage',
|
|
455
|
+
'@id': url,
|
|
456
|
+
url,
|
|
457
|
+
...(input.title ? { name: input.title } : {}),
|
|
458
|
+
...(input.description ? { description: input.description } : {}),
|
|
459
|
+
...(input.locale ? { inLanguage: input.locale } : {}),
|
|
460
|
+
...(input.websiteId ? { isPartOf: { '@id': input.websiteId } } : {}),
|
|
461
|
+
...(breadcrumbId ? { breadcrumb: { '@id': breadcrumbId } } : {}),
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
if (breadcrumbId) {
|
|
465
|
+
graph.push({
|
|
466
|
+
'@type': 'BreadcrumbList',
|
|
467
|
+
'@id': breadcrumbId,
|
|
468
|
+
itemListElement: [...crumbs, { name: input.title, item: url }].map((c, i) => ({
|
|
469
|
+
'@type': 'ListItem',
|
|
470
|
+
position: i + 1,
|
|
471
|
+
name: c.name,
|
|
472
|
+
item: c.item,
|
|
473
|
+
})),
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
if (input.article) {
|
|
477
|
+
graph.push({
|
|
478
|
+
'@type': 'Article',
|
|
479
|
+
headline: input.title,
|
|
480
|
+
...(input.description ? { description: input.description } : {}),
|
|
481
|
+
...(input.image ? { image: [input.image] } : {}),
|
|
482
|
+
...(input.publishedAt ? { datePublished: input.publishedAt } : {}),
|
|
483
|
+
...(input.modifiedAt ? { dateModified: input.modifiedAt } : {}),
|
|
484
|
+
...(input.author ? { author: { '@type': 'Person', name: input.author } } : {}),
|
|
485
|
+
...(url ? { mainEntityOfPage: { '@id': url } } : {}),
|
|
486
|
+
...(input.siteName ? { publisher: { '@type': 'Organization', name: input.siteName } } : {}),
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
return graph.length ? { '@context': 'https://schema.org', '@graph': graph } : undefined
|
|
490
|
+
}
|
|
491
|
+
|
|
312
492
|
/** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */
|
|
313
493
|
export const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)
|
|
314
494
|
|
|
@@ -390,6 +570,10 @@ export interface SeoInput {
|
|
|
390
570
|
description?: string
|
|
391
571
|
canonical?: string
|
|
392
572
|
image?: string
|
|
573
|
+
imageMeta?: ImageMeta
|
|
574
|
+
alternates?: Array<{ lang: string; path: string }>
|
|
575
|
+
breadcrumbs?: Breadcrumb[]
|
|
576
|
+
pageType?: 'WebPage' | 'CollectionPage'
|
|
393
577
|
type?: 'article' | 'website'
|
|
394
578
|
publishedAt?: string
|
|
395
579
|
modifiedAt?: string
|
|
@@ -409,6 +593,10 @@ export function postSeo(post: EmittedPost): SeoInput {
|
|
|
409
593
|
description: post.description ?? post.excerpt,
|
|
410
594
|
canonical: post.canonical,
|
|
411
595
|
image: post.image ?? featured,
|
|
596
|
+
// The measurements describe \`image\`; a \`featured\` fallback is another file.
|
|
597
|
+
imageMeta: post.image ? post.image_meta : undefined,
|
|
598
|
+
alternates: post.alternates,
|
|
599
|
+
breadcrumbs: post.breadcrumbs,
|
|
412
600
|
type: 'article',
|
|
413
601
|
publishedAt: post.published_at,
|
|
414
602
|
modifiedAt: post.modified_at,
|
|
@@ -422,6 +610,11 @@ export interface EmittedPost extends MarkablePost {
|
|
|
422
610
|
description?: string
|
|
423
611
|
/** Social image, absolute or site-root-relative. */
|
|
424
612
|
image?: string
|
|
613
|
+
image_meta?: ImageMeta
|
|
614
|
+
/** Translations of this entry, itself included, and x-default. Computed by the emitter. */
|
|
615
|
+
alternates?: Array<{ lang: string; path: string }>
|
|
616
|
+
/** Trail to this page, itself excluded. */
|
|
617
|
+
breadcrumbs?: Breadcrumb[]
|
|
425
618
|
/** Canonical override; default is the page's own address. */
|
|
426
619
|
canonical?: string
|
|
427
620
|
/** ISO 8601, for Article structured data. */
|
|
@@ -447,7 +640,9 @@ export interface EmittedQueryPage {
|
|
|
447
640
|
title?: string
|
|
448
641
|
description?: string
|
|
449
642
|
image?: string
|
|
643
|
+
image_meta?: ImageMeta
|
|
450
644
|
canonical?: string
|
|
645
|
+
breadcrumbs?: Breadcrumb[]
|
|
451
646
|
}
|
|
452
647
|
`;
|
|
453
648
|
//#endregion
|
|
@@ -571,36 +766,173 @@ function balanceWarning(html) {
|
|
|
571
766
|
const TITLE_RE = /<title\b[^>]*>[\s\S]*?<\/title>\s*/gi;
|
|
572
767
|
const META_RE = /<meta\b[^>]*\b(?:name|property)\s*=\s*["'](?:description|og:[^"']*|twitter:[^"']*)["'][^>]*>\s*/gi;
|
|
573
768
|
const CANONICAL_RE = /<link\b[^>]*\brel\s*=\s*["'][^"']*\bcanonical\b[^"']*["'][^>]*>\s*/gi;
|
|
769
|
+
/**
|
|
770
|
+
* A translation link names the template page's translations. An RSS
|
|
771
|
+
* `rel="alternate"` carries no hreflang and stays.
|
|
772
|
+
*/
|
|
773
|
+
const HREFLANG_RE = /<link\b(?=[^>]*\brel\s*=\s*["']?[^"'>]*\balternate\b)(?=[^>]*\bhreflang\s*=)[^>]*>\s*/gi;
|
|
574
774
|
const JSONLD_RE = /<script\b[^>]*\btype\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>\s*/gi;
|
|
575
775
|
/**
|
|
576
|
-
* Structured
|
|
577
|
-
*
|
|
578
|
-
*
|
|
776
|
+
* Structured data that describes THIS page. An Article names one post, a
|
|
777
|
+
* WebPage (and every subtype — CollectionPage for an archive, ProfilePage for an
|
|
778
|
+
* author) one address, a BreadcrumbList one page's trail: copied from the
|
|
779
|
+
* template, each is a lie on every other page. Matched by suffix because
|
|
780
|
+
* schema.org names its page and article subtypes that way.
|
|
579
781
|
*/
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
"
|
|
583
|
-
|
|
584
|
-
|
|
782
|
+
function isPageType(type) {
|
|
783
|
+
const t = type.toLowerCase();
|
|
784
|
+
return t.endsWith("page") || t.endsWith("article") || PAGE_POSTINGS.has(t);
|
|
785
|
+
}
|
|
786
|
+
const PAGE_POSTINGS = new Set([
|
|
585
787
|
"blogposting",
|
|
586
|
-
"
|
|
788
|
+
"liveblogposting",
|
|
789
|
+
"socialmediaposting",
|
|
790
|
+
"discussionforumposting",
|
|
791
|
+
"breadcrumblist"
|
|
792
|
+
]);
|
|
793
|
+
/**
|
|
794
|
+
* Structured data that describes the SITE and is true on every page. An
|
|
795
|
+
* SEO plugin writes it into the same `@graph` as the page's own nodes — Yoast
|
|
796
|
+
* and Rank Math put WebSite, Organization, WebPage and BreadcrumbList in one
|
|
797
|
+
* block — so removing page-scoped blocks whole took the site's identity with
|
|
798
|
+
* them.
|
|
799
|
+
*/
|
|
800
|
+
const SITE_TYPES = new Set([
|
|
801
|
+
"website",
|
|
802
|
+
"organization",
|
|
803
|
+
"corporation",
|
|
804
|
+
"localbusiness",
|
|
805
|
+
"newsmediaorganization",
|
|
806
|
+
"educationalorganization",
|
|
807
|
+
"ngo",
|
|
808
|
+
"onlinebusiness",
|
|
809
|
+
"onlinestore",
|
|
810
|
+
"governmentorganization"
|
|
587
811
|
]);
|
|
588
|
-
|
|
812
|
+
const typesOf = (node) => {
|
|
813
|
+
const type = node?.["@type"];
|
|
814
|
+
return (Array.isArray(type) ? type : [type]).filter((t) => typeof t === "string");
|
|
815
|
+
};
|
|
816
|
+
const isPageNode = (node) => typesOf(node).some(isPageType);
|
|
817
|
+
const isSiteNode = (node) => typesOf(node).some((t) => SITE_TYPES.has(t.toLowerCase()));
|
|
818
|
+
/** Every `{"@id": …}` reference anywhere inside a node. */
|
|
819
|
+
function references(value, out = /* @__PURE__ */ new Set()) {
|
|
820
|
+
if (Array.isArray(value)) for (const v of value) references(v, out);
|
|
821
|
+
else if (value && typeof value === "object") {
|
|
822
|
+
const node = value;
|
|
823
|
+
if (Object.keys(node).length === 1 && typeof node["@id"] === "string") out.add(node["@id"]);
|
|
824
|
+
for (const v of Object.values(node)) references(v, out);
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* A WebSite's SearchAction points at WordPress search (`/?s=`). A static site
|
|
830
|
+
* has no search endpoint, so the claim would be false on the new site.
|
|
831
|
+
*/
|
|
832
|
+
function withoutSearchAction(node) {
|
|
833
|
+
const action = node["potentialAction"];
|
|
834
|
+
const actions = Array.isArray(action) ? action : action === void 0 ? [] : [action];
|
|
835
|
+
const kept = actions.filter((a) => !typesOf(a).includes("SearchAction"));
|
|
836
|
+
if (kept.length === actions.length) return {
|
|
837
|
+
node,
|
|
838
|
+
dropped: false
|
|
839
|
+
};
|
|
840
|
+
const { potentialAction: _, ...rest } = node;
|
|
841
|
+
return {
|
|
842
|
+
node: kept.length ? {
|
|
843
|
+
...rest,
|
|
844
|
+
potentialAction: Array.isArray(action) ? kept : kept[0]
|
|
845
|
+
} : rest,
|
|
846
|
+
dropped: true
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* What to do with one JSON-LD block. A block with no page-scoped node stays as
|
|
851
|
+
* it was. A block with one loses its page-scoped nodes and keeps the site's —
|
|
852
|
+
* WebSite, Organization — together with the nodes those refer to by `@id` (a
|
|
853
|
+
* logo, the person a personal site is published by), never pulling a page node
|
|
854
|
+
* back in. Nothing site-scoped left means the block goes.
|
|
855
|
+
*/
|
|
856
|
+
function decideLd(json) {
|
|
589
857
|
let parsed;
|
|
590
858
|
try {
|
|
591
859
|
parsed = JSON.parse(json);
|
|
592
860
|
} catch {
|
|
593
|
-
return
|
|
861
|
+
return {
|
|
862
|
+
json: void 0,
|
|
863
|
+
kept: [],
|
|
864
|
+
droppedSearch: false
|
|
865
|
+
};
|
|
594
866
|
}
|
|
595
867
|
const graph = parsed?.["@graph"];
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
868
|
+
const nodes = Array.isArray(parsed) ? parsed : Array.isArray(graph) ? graph : [parsed];
|
|
869
|
+
if (!nodes.some(isPageNode)) return {
|
|
870
|
+
json: void 0,
|
|
871
|
+
kept: [],
|
|
872
|
+
droppedSearch: false
|
|
873
|
+
};
|
|
874
|
+
const byId = /* @__PURE__ */ new Map();
|
|
875
|
+
for (const node of nodes) {
|
|
876
|
+
const id = node?.["@id"];
|
|
877
|
+
if (typeof id === "string") byId.set(id, node);
|
|
878
|
+
}
|
|
879
|
+
const keep = new Set(nodes.filter((n) => isSiteNode(n) && !isPageNode(n)));
|
|
880
|
+
const queue = [...keep];
|
|
881
|
+
while (queue.length) for (const id of references(queue.shift())) {
|
|
882
|
+
const target = byId.get(id);
|
|
883
|
+
if (target && !keep.has(target) && !isPageNode(target)) {
|
|
884
|
+
keep.add(target);
|
|
885
|
+
queue.push(target);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (!keep.size) return {
|
|
889
|
+
json: null,
|
|
890
|
+
kept: [],
|
|
891
|
+
droppedSearch: false
|
|
892
|
+
};
|
|
893
|
+
let droppedSearch = false;
|
|
894
|
+
const kept = nodes.filter((n) => keep.has(n)).map((n) => {
|
|
895
|
+
if (!typesOf(n).some((t) => t.toLowerCase() === "website")) return n;
|
|
896
|
+
const result = withoutSearchAction(n);
|
|
897
|
+
droppedSearch ||= result.dropped;
|
|
898
|
+
return result.node;
|
|
599
899
|
});
|
|
900
|
+
const context = parsed?.["@context"];
|
|
901
|
+
const rebuilt = Array.isArray(parsed) ? kept : Array.isArray(graph) ? {
|
|
902
|
+
...context === void 0 ? {} : { "@context": context },
|
|
903
|
+
"@graph": kept
|
|
904
|
+
} : kept[0];
|
|
905
|
+
return {
|
|
906
|
+
json: JSON.stringify(rebuilt).replace(/</g, "\\u003c"),
|
|
907
|
+
kept: [...new Set(kept.flatMap(typesOf))],
|
|
908
|
+
droppedSearch
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* \`@id\` of the WebSite node in a head's structured data, if it declares one
|
|
913
|
+
* — the node the page's WebPage says it is part of. Only what the head says:
|
|
914
|
+
* no id, no link.
|
|
915
|
+
*/
|
|
916
|
+
function websiteIdOf(html) {
|
|
917
|
+
for (const match of html.matchAll(JSONLD_RE)) {
|
|
918
|
+
let parsed;
|
|
919
|
+
try {
|
|
920
|
+
parsed = JSON.parse(match[1] ?? "");
|
|
921
|
+
} catch {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const graph = parsed?.["@graph"];
|
|
925
|
+
const nodes = Array.isArray(parsed) ? parsed : Array.isArray(graph) ? graph : [parsed];
|
|
926
|
+
for (const node of nodes) {
|
|
927
|
+
const id = node?.["@id"];
|
|
928
|
+
if (typeof id === "string" && id && typesOf(node).some((t) => t.toLowerCase() === "website")) return id;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
600
931
|
}
|
|
601
932
|
/** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */
|
|
602
933
|
function stripSeoTags(html) {
|
|
603
934
|
const removed = [];
|
|
935
|
+
const kept = [];
|
|
604
936
|
const note = (label) => {
|
|
605
937
|
if (!removed.includes(label)) removed.push(label);
|
|
606
938
|
};
|
|
@@ -617,14 +949,23 @@ function stripSeoTags(html) {
|
|
|
617
949
|
note("canonical");
|
|
618
950
|
return "";
|
|
619
951
|
});
|
|
952
|
+
out = out.replace(HREFLANG_RE, () => {
|
|
953
|
+
note("hreflang");
|
|
954
|
+
return "";
|
|
955
|
+
});
|
|
620
956
|
out = out.replace(JSONLD_RE, (tag, json) => {
|
|
621
|
-
|
|
957
|
+
const decision = decideLd(json);
|
|
958
|
+
if (decision.json === void 0) return tag;
|
|
622
959
|
note("page-scoped JSON-LD");
|
|
623
|
-
|
|
960
|
+
if (decision.droppedSearch) note("WebSite SearchAction");
|
|
961
|
+
for (const type of decision.kept) if (!kept.includes(type)) kept.push(type);
|
|
962
|
+
if (decision.json === null) return "";
|
|
963
|
+
return `${tag.slice(0, tag.indexOf(">") + 1)}${decision.json}<\/script>\n`;
|
|
624
964
|
});
|
|
625
965
|
return {
|
|
626
966
|
html: out,
|
|
627
|
-
removed
|
|
967
|
+
removed,
|
|
968
|
+
kept
|
|
628
969
|
};
|
|
629
970
|
}
|
|
630
971
|
/**
|
|
@@ -660,7 +1001,7 @@ const SEO_COMPONENT = `---
|
|
|
660
1001
|
* Canonical and absolute URLs need \`site\` in astro.config.mjs; without it the
|
|
661
1002
|
* canonical link and og:url are omitted rather than pointing at a build host.
|
|
662
1003
|
*/
|
|
663
|
-
import { absoluteUrl, jsonLd, seoDescription } from '../lib/fill'
|
|
1004
|
+
import { absoluteUrl, imageMetaTags, jsonLd, pageStructuredData, seoDescription, type Breadcrumb, type ImageMeta } from '../lib/fill'
|
|
664
1005
|
|
|
665
1006
|
interface Props {
|
|
666
1007
|
title?: string
|
|
@@ -670,6 +1011,16 @@ interface Props {
|
|
|
670
1011
|
canonical?: string
|
|
671
1012
|
/** Social image: absolute, or site-root-relative. */
|
|
672
1013
|
image?: string
|
|
1014
|
+
/** Size and type of \`image\`, as the producer measured it. */
|
|
1015
|
+
imageMeta?: ImageMeta
|
|
1016
|
+
/** This page's translations, itself included, plus x-default. */
|
|
1017
|
+
alternates?: Array<{ lang: string; path: string }>
|
|
1018
|
+
/** The trail to this page, itself excluded — the page is appended with its own address. */
|
|
1019
|
+
breadcrumbs?: Breadcrumb[]
|
|
1020
|
+
/** \`CollectionPage\` on a list; \`WebPage\` otherwise. */
|
|
1021
|
+
pageType?: 'WebPage' | 'CollectionPage'
|
|
1022
|
+
/** \`@id\` of the site's WebSite node kept in the head chrome, when there is one. */
|
|
1023
|
+
websiteId?: string
|
|
673
1024
|
/** \`article\` on entry pages, \`website\` on lists and static pages. */
|
|
674
1025
|
type?: 'article' | 'website'
|
|
675
1026
|
/** ISO 8601 — structured data only; the displayed date stays a mark. */
|
|
@@ -685,6 +1036,11 @@ const {
|
|
|
685
1036
|
description,
|
|
686
1037
|
canonical,
|
|
687
1038
|
image,
|
|
1039
|
+
imageMeta,
|
|
1040
|
+
alternates = [],
|
|
1041
|
+
breadcrumbs,
|
|
1042
|
+
pageType,
|
|
1043
|
+
websiteId,
|
|
688
1044
|
type = 'website',
|
|
689
1045
|
publishedAt,
|
|
690
1046
|
modifiedAt,
|
|
@@ -696,33 +1052,47 @@ const {
|
|
|
696
1052
|
const site = Astro.site
|
|
697
1053
|
const url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(Astro.url.pathname, site)
|
|
698
1054
|
const imageUrl = absoluteUrl(image, site)
|
|
1055
|
+
const imageTags = imageUrl ? imageMetaTags(imageMeta) : {}
|
|
1056
|
+
// hreflang needs absolute URLs; without \`site\` there are none to give.
|
|
1057
|
+
const hreflang = alternates.flatMap((a) => {
|
|
1058
|
+
const href = absoluteUrl(a.path, site)
|
|
1059
|
+
return href ? [{ lang: a.lang, href }] : []
|
|
1060
|
+
})
|
|
1061
|
+
const localeAlternates = [...new Set(hreflang.map((a) => a.lang))].filter((l) => l !== 'x-default' && l !== locale)
|
|
699
1062
|
const desc = seoDescription(description)
|
|
700
1063
|
const article = type === 'article'
|
|
701
|
-
const structured =
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1064
|
+
const structured = pageStructuredData({
|
|
1065
|
+
url,
|
|
1066
|
+
site,
|
|
1067
|
+
title,
|
|
1068
|
+
description: desc,
|
|
1069
|
+
image: imageUrl,
|
|
1070
|
+
locale,
|
|
1071
|
+
article,
|
|
1072
|
+
pageType,
|
|
1073
|
+
publishedAt,
|
|
1074
|
+
modifiedAt,
|
|
1075
|
+
author,
|
|
1076
|
+
siteName,
|
|
1077
|
+
websiteId,
|
|
1078
|
+
breadcrumbs,
|
|
1079
|
+
})
|
|
715
1080
|
---
|
|
716
1081
|
<title>{title}</title>
|
|
717
1082
|
{desc && <meta name="description" content={desc} />}
|
|
718
1083
|
{url && <link rel="canonical" href={url} />}
|
|
1084
|
+
{hreflang.map((a) => <link rel="alternate" hreflang={a.lang} href={a.href} />)}
|
|
719
1085
|
<meta property="og:type" content={type} />
|
|
720
1086
|
{title && <meta property="og:title" content={title} />}
|
|
721
1087
|
{desc && <meta property="og:description" content={desc} />}
|
|
722
1088
|
{url && <meta property="og:url" content={url} />}
|
|
723
1089
|
{imageUrl && <meta property="og:image" content={imageUrl} />}
|
|
1090
|
+
{imageTags.width && <meta property="og:image:width" content={imageTags.width} />}
|
|
1091
|
+
{imageTags.height && <meta property="og:image:height" content={imageTags.height} />}
|
|
1092
|
+
{imageTags.type && <meta property="og:image:type" content={imageTags.type} />}
|
|
724
1093
|
{siteName && <meta property="og:site_name" content={siteName} />}
|
|
725
1094
|
{locale && <meta property="og:locale" content={locale} />}
|
|
1095
|
+
{localeAlternates.map((l) => <meta property="og:locale:alternate" content={l} />)}
|
|
726
1096
|
{article && publishedAt && <meta property="article:published_time" content={publishedAt} />}
|
|
727
1097
|
{article && modifiedAt && <meta property="article:modified_time" content={modifiedAt} />}
|
|
728
1098
|
{article && author && <meta property="article:author" content={author} />}
|
|
@@ -734,6 +1104,11 @@ const structured = article
|
|
|
734
1104
|
`;
|
|
735
1105
|
//#endregion
|
|
736
1106
|
//#region src/layouts.ts
|
|
1107
|
+
/** A component the family's chrome mounts at a `<!--@@component:ID@@-->` marker. */
|
|
1108
|
+
/** A safe identifier for a query id used as a module-local variable name. */
|
|
1109
|
+
function queryVar(id) {
|
|
1110
|
+
return `q_${id.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
1111
|
+
}
|
|
737
1112
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
738
1113
|
const MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\s]+)${escapeRe(CHROME_COMPONENT_CLOSE)}`, "g");
|
|
739
1114
|
/** Component ids the chrome names, in order of first appearance. */
|
|
@@ -766,11 +1141,14 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
|
|
|
766
1141
|
const mounts = [];
|
|
767
1142
|
const mount = (id, def) => {
|
|
768
1143
|
if (mounts.some((m) => m.id === id)) return;
|
|
769
|
-
const
|
|
1144
|
+
const placement = placements.get(id);
|
|
1145
|
+
const variant = placement?.variant ?? def.variants?.[0]?.key ?? "default";
|
|
1146
|
+
const query = placement?.query && (options.boundQueries?.has(placement.query) ?? false) ? placement.query : void 0;
|
|
770
1147
|
mounts.push({
|
|
771
1148
|
id,
|
|
772
1149
|
name: pascalCase(id),
|
|
773
|
-
variant
|
|
1150
|
+
variant,
|
|
1151
|
+
...query ? { query } : {}
|
|
774
1152
|
});
|
|
775
1153
|
};
|
|
776
1154
|
for (const id of componentMarkers(body)) {
|
|
@@ -795,7 +1173,10 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
|
|
|
795
1173
|
if (seoOn) {
|
|
796
1174
|
const stripped = stripSeoTags(head);
|
|
797
1175
|
head = stripped.html;
|
|
798
|
-
if (stripped.removed.length)
|
|
1176
|
+
if (stripped.removed.length) {
|
|
1177
|
+
const kept = stripped.kept.length ? `; kept the site-wide ${stripped.kept.join(", ")} from its structured data` : "";
|
|
1178
|
+
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
|
+
}
|
|
799
1180
|
}
|
|
800
1181
|
const files = {};
|
|
801
1182
|
files[`src/data/chrome/${family.id}.json`] = stableJson({
|
|
@@ -806,26 +1187,34 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
|
|
|
806
1187
|
});
|
|
807
1188
|
const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
|
|
808
1189
|
const siteNameProp = options.siteName ? ` siteName={${JSON.stringify(options.siteName)}}` : "";
|
|
1190
|
+
const websiteId = seoOn ? websiteIdOf(head) : void 0;
|
|
1191
|
+
const websiteIdProp = websiteId ? ` websiteId={${JSON.stringify(websiteId)}}` : "";
|
|
809
1192
|
const imported = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])];
|
|
810
1193
|
const componentImports = [...seoOn ? [`import Seo from '../components/Seo.astro'`] : [], ...imported.map((n) => `import ${n} from '../components/${n}.astro'`)].join("\n");
|
|
811
1194
|
const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(" | ");
|
|
1195
|
+
const queryMounts = mounts.filter((m) => m.query);
|
|
1196
|
+
const queryImports = [...new Set(queryMounts.map((m) => m.query))].map((q) => `import ${queryVar(q)} from '../data/queries/${q}.json'`).join("\n");
|
|
1197
|
+
const queryRender = queryMounts.length ? `${[...new Set(queryMounts.map((m) => m.query))].map((q) => `const ${queryVar(q)}Html = renderQuery(${queryVar(q)} as EmittedQueryPage[], ${JSON.stringify(q)})`).join("\n")}\n` : "";
|
|
812
1198
|
const mountTable = mounts.length ? `// Components the chrome mounts at <!--@@component:ID@@--> markers; the page's
|
|
813
1199
|
// entry address travels to each so a comments thread knows which entry it is.
|
|
814
|
-
|
|
815
|
-
|
|
1200
|
+
// A mount with \`html\` renders a query's results rather than cloned markup.
|
|
1201
|
+
const mounts: Record<string, { Mount: ${mountType}; variant: string; html?: string } | undefined> = {
|
|
1202
|
+
${mounts.map((m) => ` ${JSON.stringify(m.id)}: { Mount: ${m.name}, variant: ${JSON.stringify(m.variant)}${m.query ? `, html: ${queryVar(m.query)}Html` : ""} },`).join("\n")}
|
|
816
1203
|
}
|
|
817
1204
|
const parts = splitComponents(html).map((part) => ({
|
|
818
1205
|
html: part.html,
|
|
819
1206
|
Mount: part.component ? mounts[part.component]?.Mount : undefined,
|
|
820
1207
|
variant: part.component ? (mounts[part.component]?.variant ?? 'default') : 'default',
|
|
1208
|
+
listHtml: part.component ? mounts[part.component]?.html : undefined,
|
|
821
1209
|
}))
|
|
822
1210
|
` : "";
|
|
823
|
-
const bodyRender = mounts.length ? ` {parts.map(({ html: part, Mount, variant }) => (Mount ? <Mount entry={entry} variant={variant} /> : <Fragment set:html={part} />))}` : ` <Fragment set:html={html} />`;
|
|
1211
|
+
const bodyRender = mounts.length ? ` {parts.map(({ html: part, Mount, variant, listHtml }) => (Mount ? <Mount entry={entry} variant={variant} html={listHtml} lang={htmlAttrs.lang} /> : <Fragment set:html={part} />))}` : ` <Fragment set:html={html} />`;
|
|
824
1212
|
const renderRefs = (position) => components.filter((c) => c.position === position).map((c) => ` <${c.name} marks={marks} />`).join("\n");
|
|
825
1213
|
files[`src/layouts/${name}.astro`] = `---
|
|
826
1214
|
// Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
|
|
827
1215
|
import chrome from '../data/chrome/${family.id}.json'
|
|
828
|
-
import { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ", splitComponents" : ""}${seoOn ? ", type SeoInput" : ""} } from '../lib/fill'
|
|
1216
|
+
import { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ", splitComponents" : ""}${queryMounts.length ? ", renderQuery, type EmittedQueryPage" : ""}${seoOn ? ", type SeoInput" : ""} } from '../lib/fill'
|
|
1217
|
+
${queryImports}${queryImports ? "\n" : ""}
|
|
829
1218
|
${componentImports ? `${componentImports}\n` : ""}
|
|
830
1219
|
interface Props {
|
|
831
1220
|
title?: string
|
|
@@ -851,7 +1240,7 @@ const content = body ?? (Astro.slots.has('default') ? await Astro.slots.render('
|
|
|
851
1240
|
// Split at the marker FIRST, then fill marks per side — filling first would
|
|
852
1241
|
// eat the @@body@@ inside the marker and silently drop the content.
|
|
853
1242
|
const html = composeBody(chrome.body, marks, content)
|
|
854
|
-
${mountTable}---
|
|
1243
|
+
${queryRender}${mountTable}---
|
|
855
1244
|
<!doctype html>
|
|
856
1245
|
<html {...htmlAttrs}>
|
|
857
1246
|
<head>
|
|
@@ -862,7 +1251,7 @@ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
|
|
|
862
1251
|
<link rel="stylesheet" href={cssHref(file)} />
|
|
863
1252
|
))}
|
|
864
1253
|
<Fragment set:html={head} />
|
|
865
|
-
${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
|
|
1254
|
+
${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp}${websiteIdProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
|
|
866
1255
|
</head>
|
|
867
1256
|
<body {...bodyAttrs}>
|
|
868
1257
|
${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
|
|
@@ -977,18 +1366,13 @@ const { page } = Astro.props as Props` : `const page = (data as EmittedQueryPage
|
|
|
977
1366
|
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
978
1367
|
import Layout from '${up}layouts/${layout}.astro'
|
|
979
1368
|
import data from '${up}data/queries/${route.query}.json'
|
|
980
|
-
import {
|
|
1369
|
+
import { renderQueryPage, type EmittedQueryPage } from '${up}lib/fill'
|
|
981
1370
|
|
|
982
1371
|
${paths}
|
|
983
1372
|
// A list renders in sections (a big card then a grid) — one template is the
|
|
984
|
-
// single-section case. Without a template at all
|
|
1373
|
+
// single-section case. Without a template at all it falls back to a plain list
|
|
985
1374
|
// and the emitter has already warned: silence would read as fidelity.
|
|
986
|
-
const
|
|
987
|
-
const content = sections.length
|
|
988
|
-
? renderSections(sections, page.items, postMarks)
|
|
989
|
-
: '<ul class="cr-post-list">' +
|
|
990
|
-
page.items.map((item) => '<li><a href="/' + item.slug + '/">' + esc(item.title) + '</a></li>').join('') +
|
|
991
|
-
'</ul>'
|
|
1375
|
+
const content = renderQueryPage(page)
|
|
992
1376
|
// Route params carry slugs; page marks carry what the chrome needs to SHOW
|
|
993
1377
|
// (a term's display name, description, count).
|
|
994
1378
|
const marks = { ...page.params, ...(page.marks ?? {}) }
|
|
@@ -1000,7 +1384,7 @@ const title = page.title ?? ${routeTitle}
|
|
|
1000
1384
|
body={content}
|
|
1001
1385
|
css={page.css ?? []}
|
|
1002
1386
|
lang={${locale}}${seo ? `
|
|
1003
|
-
seo={{ description: page.description, image: page.image, canonical: page.canonical, type: 'website' }}` : ""}
|
|
1387
|
+
seo={{ description: page.description, image: page.image, imageMeta: page.image_meta, canonical: page.canonical, breadcrumbs: page.breadcrumbs, type: 'website', pageType: 'CollectionPage' }}` : ""}
|
|
1004
1388
|
/>
|
|
1005
1389
|
`;
|
|
1006
1390
|
}
|
|
@@ -1017,6 +1401,45 @@ ${paths}---
|
|
|
1017
1401
|
<Layout title=${JSON.stringify(route.title ?? "")} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}}${seo ? ` seo={{ type: 'website' }}` : ""} />
|
|
1018
1402
|
`;
|
|
1019
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
|
+
}))
|
|
1433
|
+
}
|
|
1434
|
+
const { post, routeIndex } = Astro.props as { post: EmittedPost, routeIndex: number }
|
|
1435
|
+
const layouts = [${routes.map((_, i) => `Layout${i}`).join(", ")}]
|
|
1436
|
+
const locales = ${JSON.stringify(routes.map((r) => r.locale ?? siteLocale))}
|
|
1437
|
+
const Layout = layouts[routeIndex]!
|
|
1438
|
+
---
|
|
1439
|
+
<Layout title={post.title} marks={postMarks(post)} body={post.body}
|
|
1440
|
+
css={post.css ?? []} lang={post.locale ?? locales[routeIndex]} entry={post.entry}${seo ? " seo={postSeo(post)}" : ""} />
|
|
1441
|
+
`;
|
|
1442
|
+
}
|
|
1020
1443
|
//#endregion
|
|
1021
1444
|
//#region src/embed.ts
|
|
1022
1445
|
const EMBED_TS = `// Emitted by @contentrain/emitter-astro — the runtime-component client.
|
|
@@ -1051,7 +1474,11 @@ export class EmbedError extends Error {
|
|
|
1051
1474
|
}
|
|
1052
1475
|
}
|
|
1053
1476
|
|
|
1054
|
-
/**
|
|
1477
|
+
/**
|
|
1478
|
+
* Visitor-facing text. The component passes the page's text from the site's
|
|
1479
|
+
* ui-strings dictionary (\`data-strings\`); these are the defaults it falls
|
|
1480
|
+
* back to, key for key.
|
|
1481
|
+
*/
|
|
1055
1482
|
export const strings = {
|
|
1056
1483
|
loading: 'Loading…',
|
|
1057
1484
|
send: 'Send',
|
|
@@ -1537,6 +1964,52 @@ export function resetCaptcha(root: ParentNode): void {
|
|
|
1537
1964
|
|
|
1538
1965
|
// ─── Mounting (the only DOM code) ───
|
|
1539
1966
|
|
|
1967
|
+
/** ui-strings dictionary key → the \`strings\` entry it sets. */
|
|
1968
|
+
export const STRING_KEYS: ReadonlyArray<readonly [string, keyof typeof strings]> = [
|
|
1969
|
+
['common.loading', 'loading'],
|
|
1970
|
+
['common.failed', 'failed'],
|
|
1971
|
+
['form.send', 'send'],
|
|
1972
|
+
['form.sending', 'sending'],
|
|
1973
|
+
['form.honeypot', 'honeypot'],
|
|
1974
|
+
['comments.title', 'commentsTitle'],
|
|
1975
|
+
['comments.empty', 'noComments'],
|
|
1976
|
+
['comments.closed', 'closed'],
|
|
1977
|
+
['comments.post', 'postComment'],
|
|
1978
|
+
['comments.reply', 'reply'],
|
|
1979
|
+
['comments.replying_to', 'replyingTo'],
|
|
1980
|
+
['comments.cancel', 'cancel'],
|
|
1981
|
+
['comments.load_more', 'loadMore'],
|
|
1982
|
+
['comments.moderator', 'moderator'],
|
|
1983
|
+
['comments.pending', 'pending'],
|
|
1984
|
+
['comments.posted', 'posted'],
|
|
1985
|
+
['comments.field.name', 'name'],
|
|
1986
|
+
['comments.field.email', 'email'],
|
|
1987
|
+
['comments.field.email_note', 'emailNote'],
|
|
1988
|
+
['comments.field.website', 'website'],
|
|
1989
|
+
['comments.field.comment', 'comment'],
|
|
1990
|
+
]
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* Take the page's interface text from the host element. A value that is not a
|
|
1994
|
+
* non-empty string leaves the default; text that cannot be parsed changes
|
|
1995
|
+
* nothing, because a mount that fails over a label is worse than an English one.
|
|
1996
|
+
*/
|
|
1997
|
+
export function applyStrings(host: HTMLElement): void {
|
|
1998
|
+
const raw = host.dataset.strings
|
|
1999
|
+
if (!raw) return
|
|
2000
|
+
let text: unknown
|
|
2001
|
+
try {
|
|
2002
|
+
text = JSON.parse(raw)
|
|
2003
|
+
} catch {
|
|
2004
|
+
return
|
|
2005
|
+
}
|
|
2006
|
+
if (!text || typeof text !== 'object') return
|
|
2007
|
+
for (const [key, name] of STRING_KEYS) {
|
|
2008
|
+
const value = (text as Record<string, unknown>)[key]
|
|
2009
|
+
if (typeof value === 'string' && value !== '') strings[name] = value
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
1540
2013
|
export function runtimeOf(host: HTMLElement): Runtime {
|
|
1541
2014
|
return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }
|
|
1542
2015
|
}
|
|
@@ -1570,6 +2043,7 @@ function entriesOf(form: HTMLFormElement): Array<[string, unknown]> {
|
|
|
1570
2043
|
|
|
1571
2044
|
/** \`<cr-form data-base-url data-project data-model>\` → fetch the config, render, submit. */
|
|
1572
2045
|
export async function mountForm(host: HTMLElement): Promise<void> {
|
|
2046
|
+
applyStrings(host)
|
|
1573
2047
|
const rt = runtimeOf(host)
|
|
1574
2048
|
const model = host.dataset.model ?? ''
|
|
1575
2049
|
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
@@ -1607,6 +2081,7 @@ export async function mountForm(host: HTMLElement): Promise<void> {
|
|
|
1607
2081
|
|
|
1608
2082
|
/** \`<cr-comments data-base-url data-project data-model data-entry data-locale>\` → thread + form. */
|
|
1609
2083
|
export async function mountComments(host: HTMLElement): Promise<void> {
|
|
2084
|
+
applyStrings(host)
|
|
1610
2085
|
const rt = runtimeOf(host)
|
|
1611
2086
|
const entry = entryOf(host)
|
|
1612
2087
|
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
@@ -1713,6 +2188,110 @@ export async function mountComments(host: HTMLElement): Promise<void> {
|
|
|
1713
2188
|
}
|
|
1714
2189
|
`;
|
|
1715
2190
|
//#endregion
|
|
2191
|
+
//#region src/ui-strings.ts
|
|
2192
|
+
/** Where the dictionary lives in an emitted project, relative to its root. */
|
|
2193
|
+
const UI_STRINGS_DIR = ".contentrain/content/site/ui-strings";
|
|
2194
|
+
/** Every key the runtime components read, with the text they show without a dictionary. */
|
|
2195
|
+
const UI_STRING_DEFAULTS = {
|
|
2196
|
+
"comments.cancel": "Cancel",
|
|
2197
|
+
"comments.closed": "Comments are closed.",
|
|
2198
|
+
"comments.empty": "No comments yet.",
|
|
2199
|
+
"comments.field.comment": "Comment",
|
|
2200
|
+
"comments.field.email": "Email",
|
|
2201
|
+
"comments.field.email_note": "(never shown)",
|
|
2202
|
+
"comments.field.name": "Name",
|
|
2203
|
+
"comments.field.website": "Website",
|
|
2204
|
+
"comments.load_more": "Load more comments",
|
|
2205
|
+
"comments.moderator": "Moderator",
|
|
2206
|
+
"comments.noscript": "Comments need JavaScript.",
|
|
2207
|
+
"comments.pending": "Thank you — your comment is awaiting moderation.",
|
|
2208
|
+
"comments.post": "Post comment",
|
|
2209
|
+
"comments.posted": "Your comment has been posted.",
|
|
2210
|
+
"comments.replying_to": "Replying to",
|
|
2211
|
+
"comments.reply": "Reply",
|
|
2212
|
+
"comments.title": "Comments",
|
|
2213
|
+
"common.failed": "Something went wrong. Please try again.",
|
|
2214
|
+
"common.loading": "Loading…",
|
|
2215
|
+
"form.honeypot": "Leave this field empty",
|
|
2216
|
+
"form.noscript": "This form needs JavaScript.",
|
|
2217
|
+
"form.send": "Send",
|
|
2218
|
+
"form.sending": "Sending…"
|
|
2219
|
+
};
|
|
2220
|
+
/**
|
|
2221
|
+
* The dictionary model a producer creates in the site's store. Exported so the
|
|
2222
|
+
* migration and the emitter cannot disagree about its id, kind or domain.
|
|
2223
|
+
*/
|
|
2224
|
+
const UI_STRINGS_MODEL = {
|
|
2225
|
+
id: "ui-strings",
|
|
2226
|
+
name: "Site interface text",
|
|
2227
|
+
kind: "dictionary",
|
|
2228
|
+
domain: "site",
|
|
2229
|
+
i18n: true,
|
|
2230
|
+
title_field: DICTIONARY_TITLE_FIELD,
|
|
2231
|
+
description: "What the site’s comments and forms say to visitors, per language. Missing keys fall back to English."
|
|
2232
|
+
};
|
|
2233
|
+
/**
|
|
2234
|
+
* A project-relative directory safe to put in a glob, or undefined. The value
|
|
2235
|
+
* lands in source the build evaluates, so anything that could leave the
|
|
2236
|
+
* project or change the pattern is refused rather than escaped.
|
|
2237
|
+
*/
|
|
2238
|
+
function uiStringsDir(dir) {
|
|
2239
|
+
const value = (dir ?? ".contentrain/content/site/ui-strings").replace(/^\/+|\/+$/g, "");
|
|
2240
|
+
if (!value || value.split("/").some((s) => s === ".." || s === "." || s === "") || !/^[\w.@-]+(?:\/[\w.@-]+)*$/.test(value)) return void 0;
|
|
2241
|
+
return value;
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* `src/lib/ui-strings.ts` for an emitted project: reads every locale file of
|
|
2245
|
+
* the dictionary at build time and resolves a page's text over the defaults.
|
|
2246
|
+
*
|
|
2247
|
+
* `import.meta.glob` returns an empty map when nothing matches, so a project
|
|
2248
|
+
* without the dictionary still builds. It does not stay quiet about it: a page
|
|
2249
|
+
* in a language other than English with no dictionary, or a dictionary missing
|
|
2250
|
+
* keys, is reported once in the build log.
|
|
2251
|
+
*/
|
|
2252
|
+
function uiStringsSource(dir) {
|
|
2253
|
+
const pattern = `/${dir}/*.json`;
|
|
2254
|
+
return `// Emitted by @contentrain/emitter-astro — the site's interface text.
|
|
2255
|
+
//
|
|
2256
|
+
// Comments and forms read their visitor-facing text from the ui-strings
|
|
2257
|
+
// dictionary in the content store (${dir}/{locale}.json), so it is
|
|
2258
|
+
// edited as content — in Studio or through MCP — and a rebuild applies it.
|
|
2259
|
+
// Keys the dictionary does not have show the English default below.
|
|
2260
|
+
|
|
2261
|
+
const DEFAULTS: Record<string, string> = ${JSON.stringify(UI_STRING_DEFAULTS, null, 2)}
|
|
2262
|
+
|
|
2263
|
+
const files = import.meta.glob<Record<string, unknown>>(${JSON.stringify(pattern)}, { eager: true, import: 'default' })
|
|
2264
|
+
const dictionaries = new Map(
|
|
2265
|
+
Object.entries(files).map(([path, dictionary]) => [path.slice(path.lastIndexOf('/') + 1, -5).toLowerCase(), dictionary]),
|
|
2266
|
+
)
|
|
2267
|
+
const reported = new Set<string>()
|
|
2268
|
+
|
|
2269
|
+
/** The interface text for a page language: the dictionary's values over the defaults. */
|
|
2270
|
+
export function uiStrings(lang: string | undefined): Record<string, string> {
|
|
2271
|
+
const locale = (lang || 'en').toLowerCase()
|
|
2272
|
+
// tr-TR reads tr.json when there is no tr-tr.json — the store keys by primary subtag.
|
|
2273
|
+
const name = dictionaries.has(locale) ? locale : (locale.split('-')[0] ?? locale)
|
|
2274
|
+
const dictionary = dictionaries.get(name)
|
|
2275
|
+
const text: Record<string, string> = { ...DEFAULTS }
|
|
2276
|
+
const missing: string[] = []
|
|
2277
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
2278
|
+
const value = dictionary?.[key]
|
|
2279
|
+
if (typeof value === 'string' && value !== '') text[key] = value
|
|
2280
|
+
else missing.push(key)
|
|
2281
|
+
}
|
|
2282
|
+
if (!name.startsWith('en') && missing.length && !reported.has(name)) {
|
|
2283
|
+
reported.add(name)
|
|
2284
|
+
console.warn(
|
|
2285
|
+
dictionary
|
|
2286
|
+
? '[contentrain] ui-strings: ' + name + '.json has no text for ' + missing.join(', ') + ' — those show in English'
|
|
2287
|
+
: '[contentrain] ui-strings: no dictionary for "' + name + '" at ${dir}/' + name + '.json — comments and forms show English text',
|
|
2288
|
+
)
|
|
2289
|
+
}
|
|
2290
|
+
return text
|
|
2291
|
+
}
|
|
2292
|
+
`;
|
|
2293
|
+
}
|
|
2294
|
+
//#endregion
|
|
1716
2295
|
//#region src/components.ts
|
|
1717
2296
|
/** Component types this emitter implements against a runtime binding. */
|
|
1718
2297
|
const RUNTIME_IMPLEMENTED = new Set(["comments", "form"]);
|
|
@@ -1720,7 +2299,7 @@ function isRuntimeImplemented(c, runtime) {
|
|
|
1720
2299
|
if (!runtime || c.source !== "runtime" || !RUNTIME_IMPLEMENTED.has(c.type)) return false;
|
|
1721
2300
|
return c.type !== "form" || Boolean(c.model);
|
|
1722
2301
|
}
|
|
1723
|
-
function componentFiles(components, runtime) {
|
|
2302
|
+
function componentFiles(components, runtime, queryBound = /* @__PURE__ */ new Set(), uiStringsDir = ".contentrain/content/site/ui-strings") {
|
|
1724
2303
|
const files = {};
|
|
1725
2304
|
const warnings = [];
|
|
1726
2305
|
let needsRuntime = false;
|
|
@@ -1732,7 +2311,7 @@ function componentFiles(components, runtime) {
|
|
|
1732
2311
|
continue;
|
|
1733
2312
|
}
|
|
1734
2313
|
if (c.source === "runtime" && RUNTIME_IMPLEMENTED.has(c.type)) warnings.push(!runtime ? `component ${c.id} (${c.type}): no runtime binding given — emitted as a placeholder; pass input.runtime to mount it` : `component ${c.id} (form): no model named — a form must say which collection it submits to; emitted as a placeholder`);
|
|
1735
|
-
files[`src/components/${name}.astro`] = placeholderSource(c);
|
|
2314
|
+
files[`src/components/${name}.astro`] = placeholderSource(c, queryBound.has(c.id));
|
|
1736
2315
|
}
|
|
1737
2316
|
if (needsRuntime && runtime) {
|
|
1738
2317
|
files["src/data/runtime.json"] = stableJson({
|
|
@@ -1740,6 +2319,7 @@ function componentFiles(components, runtime) {
|
|
|
1740
2319
|
project_id: runtime.project_id
|
|
1741
2320
|
});
|
|
1742
2321
|
files["src/lib/embed.ts"] = EMBED_TS;
|
|
2322
|
+
files["src/lib/ui-strings.ts"] = uiStringsSource(uiStringsDir);
|
|
1743
2323
|
}
|
|
1744
2324
|
return {
|
|
1745
2325
|
files,
|
|
@@ -1752,18 +2332,29 @@ function header(c, extra = "") {
|
|
|
1752
2332
|
* Emitted by @contentrain/emitter-astro.${extra}
|
|
1753
2333
|
*/`;
|
|
1754
2334
|
}
|
|
1755
|
-
function placeholderSource(c) {
|
|
2335
|
+
function placeholderSource(c, queryBound) {
|
|
1756
2336
|
const variants = (c.variants ?? []).map((v) => v.key);
|
|
1757
2337
|
return `---
|
|
1758
|
-
${header(c, `${c.source === "runtime" ? "\n * Placeholder — needs a live provider; see the migration handoff offers." : " Placeholder."}${variants.length ? `\n * Variants: ${variants.join(", ")}` : ""}`)}
|
|
2338
|
+
${header(c, `${c.source === "runtime" ? "\n * Placeholder — needs a live provider; see the migration handoff offers." : " Placeholder."}${queryBound ? "\n * A placement binds this region to a query; the layout renders it and passes the html." : ""}${variants.length ? `\n * Variants: ${variants.join(", ")}` : ""}`)}
|
|
1759
2339
|
interface Props {
|
|
1760
2340
|
variant?: string
|
|
1761
2341
|
/** Content-store address of the page's entry; unused by a placeholder. */
|
|
1762
2342
|
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
2343
|
+
/**
|
|
2344
|
+
* Rendered list markup when a placement binds this region to a query. The
|
|
2345
|
+
* layout resolves the data — a component file is shared by id across
|
|
2346
|
+
* families, a placement belongs to one — so the region is editable here
|
|
2347
|
+
* while staying current there.
|
|
2348
|
+
*/
|
|
2349
|
+
html?: string
|
|
2350
|
+
/** The page's language; unused by a placeholder. */
|
|
2351
|
+
lang?: string
|
|
1763
2352
|
}
|
|
1764
|
-
const { variant = ${JSON.stringify(variants[0] ?? "default")} } = Astro.props
|
|
2353
|
+
const { variant = ${JSON.stringify(variants[0] ?? "default")}, html } = Astro.props
|
|
1765
2354
|
---
|
|
1766
|
-
|
|
2355
|
+
{html
|
|
2356
|
+
? <div class="cr-query" data-type=${JSON.stringify(c.type)} data-variant={variant} set:html={html} />
|
|
2357
|
+
: <cr-component data-type=${JSON.stringify(c.type)} data-variant={variant}></cr-component>}
|
|
1767
2358
|
`;
|
|
1768
2359
|
}
|
|
1769
2360
|
function commentsSource(c) {
|
|
@@ -1774,13 +2365,19 @@ ${header(c, `
|
|
|
1774
2365
|
* moderation happens on the provider. Renders nothing on a page without an
|
|
1775
2366
|
* entry address (a list, a static page).`)}
|
|
1776
2367
|
import runtime from '../data/runtime.json'
|
|
2368
|
+
import { uiStrings } from '../lib/ui-strings'
|
|
1777
2369
|
|
|
1778
2370
|
interface Props {
|
|
1779
2371
|
/** Content-store address of the page's entry — the thread's key. */
|
|
1780
2372
|
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
1781
2373
|
variant?: string
|
|
2374
|
+
/** Accepted for uniform mounting — the layout passes it to every mount; a thread renders its own markup. */
|
|
2375
|
+
html?: string
|
|
2376
|
+
/** The page's language — which ui-strings dictionary the thread's text comes from. */
|
|
2377
|
+
lang?: string
|
|
1782
2378
|
}
|
|
1783
|
-
const { entry, variant = 'default' } = Astro.props
|
|
2379
|
+
const { entry, variant = 'default', lang } = Astro.props
|
|
2380
|
+
const text = uiStrings(lang)
|
|
1784
2381
|
---
|
|
1785
2382
|
{entry && (
|
|
1786
2383
|
<cr-comments
|
|
@@ -1791,8 +2388,9 @@ const { entry, variant = 'default' } = Astro.props
|
|
|
1791
2388
|
data-entry={entry.entry_id}
|
|
1792
2389
|
data-locale={entry.locale}
|
|
1793
2390
|
data-variant={variant}
|
|
2391
|
+
data-strings={JSON.stringify(text)}
|
|
1794
2392
|
>
|
|
1795
|
-
<noscript><p class="cr-noscript">
|
|
2393
|
+
<noscript><p class="cr-noscript">{text['comments.noscript']}</p></noscript>
|
|
1796
2394
|
</cr-comments>
|
|
1797
2395
|
)}
|
|
1798
2396
|
|
|
@@ -1817,13 +2415,19 @@ ${header(c, `
|
|
|
1817
2415
|
* Submissions land on the provider for moderation; an approved one becomes a
|
|
1818
2416
|
* content entry there.`)}
|
|
1819
2417
|
import runtime from '../data/runtime.json'
|
|
2418
|
+
import { uiStrings } from '../lib/ui-strings'
|
|
1820
2419
|
|
|
1821
2420
|
interface Props {
|
|
1822
2421
|
variant?: string
|
|
1823
2422
|
/** Accepted for uniform mounting; a form is addressed by its model, not by the page's entry. */
|
|
1824
2423
|
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
2424
|
+
/** Accepted for uniform mounting — the layout passes it to every mount; a form renders its own markup. */
|
|
2425
|
+
html?: string
|
|
2426
|
+
/** The page's language — which ui-strings dictionary the form's text comes from. */
|
|
2427
|
+
lang?: string
|
|
1825
2428
|
}
|
|
1826
|
-
const { variant = 'default' } = Astro.props
|
|
2429
|
+
const { variant = 'default', lang } = Astro.props
|
|
2430
|
+
const text = uiStrings(lang)
|
|
1827
2431
|
---
|
|
1828
2432
|
<cr-form
|
|
1829
2433
|
class={\`cr-form-mount cr-form-mount--\${variant}\`}
|
|
@@ -1831,8 +2435,9 @@ const { variant = 'default' } = Astro.props
|
|
|
1831
2435
|
data-project={runtime.project_id}
|
|
1832
2436
|
data-model=${JSON.stringify(c.model)}
|
|
1833
2437
|
data-variant={variant}
|
|
2438
|
+
data-strings={JSON.stringify(text)}
|
|
1834
2439
|
>
|
|
1835
|
-
<noscript><p class="cr-noscript">
|
|
2440
|
+
<noscript><p class="cr-noscript">{text['form.noscript']}</p></noscript>
|
|
1836
2441
|
</cr-form>
|
|
1837
2442
|
|
|
1838
2443
|
<script>
|
|
@@ -1919,6 +2524,134 @@ function chromeComponents(families) {
|
|
|
1919
2524
|
};
|
|
1920
2525
|
}
|
|
1921
2526
|
//#endregion
|
|
2527
|
+
//#region src/alternates.ts
|
|
2528
|
+
/**
|
|
2529
|
+
* The path Astro builds for a route pattern and a set of parameters, in the
|
|
2530
|
+
* directory format the scaffold configures (`/en/hello/`). Null when a
|
|
2531
|
+
* parameter has no value — that page has no address to name.
|
|
2532
|
+
*/
|
|
2533
|
+
function entryPath(pattern, params) {
|
|
2534
|
+
const segments = pattern.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
2535
|
+
if (!segments.length) return "/";
|
|
2536
|
+
const out = [];
|
|
2537
|
+
for (const segment of segments) {
|
|
2538
|
+
if (!segment.startsWith(":")) {
|
|
2539
|
+
out.push(segment);
|
|
2540
|
+
continue;
|
|
2541
|
+
}
|
|
2542
|
+
const value = params[segment.slice(1).replace(/\*$/, "")]?.replace(/^\/+|\/+$/g, "");
|
|
2543
|
+
if (!value) return null;
|
|
2544
|
+
out.push(value);
|
|
2545
|
+
}
|
|
2546
|
+
return `/${out.join("/")}/`;
|
|
2547
|
+
}
|
|
2548
|
+
const byLangTag = (a, b) => a.lang < b.lang ? -1 : a.lang > b.lang ? 1 : 0;
|
|
2549
|
+
/**
|
|
2550
|
+
* Attach `alternates` to every entry that has translations on the site.
|
|
2551
|
+
*
|
|
2552
|
+
* The language of a page is the one its `<html lang>` gets: the post's own
|
|
2553
|
+
* locale, else its route's, else the site's. A site with one language has no
|
|
2554
|
+
* alternates and nothing is attached.
|
|
2555
|
+
*/
|
|
2556
|
+
function withAlternates(routes, content, siteLocale) {
|
|
2557
|
+
const warnings = [];
|
|
2558
|
+
const placements = /* @__PURE__ */ new Map();
|
|
2559
|
+
for (const route of routes) {
|
|
2560
|
+
if (route.kind !== "single" && route.collection === void 0) continue;
|
|
2561
|
+
const pagePath = patternToPagePath(route.pattern);
|
|
2562
|
+
if (!pagePath) continue;
|
|
2563
|
+
const collection = route.collection ?? "posts";
|
|
2564
|
+
const posts = collectionItems(content, collection);
|
|
2565
|
+
if (!pagePath.includes("[") && posts.length !== 1) continue;
|
|
2566
|
+
for (const post of posts) {
|
|
2567
|
+
const list = placements.get(post) ?? [];
|
|
2568
|
+
list.push({
|
|
2569
|
+
lang: post.locale ?? route.locale ?? siteLocale,
|
|
2570
|
+
path: entryPath(route.pattern, {
|
|
2571
|
+
...post.params,
|
|
2572
|
+
slug: post.slug
|
|
2573
|
+
}),
|
|
2574
|
+
key: post.entry ? JSON.stringify([post.entry.model_id, post.entry.entry_id]) : void 0,
|
|
2575
|
+
collection
|
|
2576
|
+
});
|
|
2577
|
+
placements.set(post, list);
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
if (new Set([...placements.values()].flat().map((p) => p.lang)).size < 2) return {
|
|
2581
|
+
content,
|
|
2582
|
+
warnings
|
|
2583
|
+
};
|
|
2584
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2585
|
+
const unkeyed = /* @__PURE__ */ new Map();
|
|
2586
|
+
const ambiguous = /* @__PURE__ */ new Map();
|
|
2587
|
+
for (const [post, list] of placements) {
|
|
2588
|
+
const first = list[0];
|
|
2589
|
+
if (list.length > 1 || first.path === null) {
|
|
2590
|
+
ambiguous.set(first.collection, (ambiguous.get(first.collection) ?? 0) + 1);
|
|
2591
|
+
continue;
|
|
2592
|
+
}
|
|
2593
|
+
if (!first.key) {
|
|
2594
|
+
unkeyed.set(first.collection, (unkeyed.get(first.collection) ?? 0) + 1);
|
|
2595
|
+
continue;
|
|
2596
|
+
}
|
|
2597
|
+
const group = groups.get(first.key) ?? [];
|
|
2598
|
+
group.push({
|
|
2599
|
+
post,
|
|
2600
|
+
lang: first.lang,
|
|
2601
|
+
path: first.path
|
|
2602
|
+
});
|
|
2603
|
+
groups.set(first.key, group);
|
|
2604
|
+
}
|
|
2605
|
+
for (const [collection, count] of unkeyed) warnings.push(`collection ${collection}: ${count} posts carry no entry address — hreflang cannot link them to their translations`);
|
|
2606
|
+
for (const [collection, count] of ambiguous) warnings.push(`collection ${collection}: ${count} posts have no single address (served by more than one route, or a route parameter without a value) — no hreflang on those pages`);
|
|
2607
|
+
const alternatesOf = /* @__PURE__ */ new Map();
|
|
2608
|
+
for (const group of groups.values()) {
|
|
2609
|
+
if (group.length < 2) continue;
|
|
2610
|
+
const byLang = /* @__PURE__ */ new Map();
|
|
2611
|
+
let conflict = false;
|
|
2612
|
+
for (const member of group) {
|
|
2613
|
+
const seen = byLang.get(member.lang);
|
|
2614
|
+
if (seen !== void 0 && seen !== member.path) conflict = true;
|
|
2615
|
+
byLang.set(member.lang, member.path);
|
|
2616
|
+
}
|
|
2617
|
+
const entry = group[0].post.entry;
|
|
2618
|
+
if (conflict) {
|
|
2619
|
+
warnings.push(`entry ${entry.model_id}/${entry.entry_id}: two pages claim the same language — no hreflang for this entry`);
|
|
2620
|
+
continue;
|
|
2621
|
+
}
|
|
2622
|
+
if (byLang.size < 2) continue;
|
|
2623
|
+
const alternates = [...byLang].map(([lang, path]) => ({
|
|
2624
|
+
lang,
|
|
2625
|
+
path
|
|
2626
|
+
})).toSorted(byLangTag);
|
|
2627
|
+
const fallback = byLang.get(siteLocale);
|
|
2628
|
+
if (fallback !== void 0) alternates.push({
|
|
2629
|
+
lang: "x-default",
|
|
2630
|
+
path: fallback
|
|
2631
|
+
});
|
|
2632
|
+
for (const member of group) alternatesOf.set(member.post, alternates);
|
|
2633
|
+
}
|
|
2634
|
+
if (!alternatesOf.size) return {
|
|
2635
|
+
content,
|
|
2636
|
+
warnings
|
|
2637
|
+
};
|
|
2638
|
+
const mark = (posts) => posts.map((post) => {
|
|
2639
|
+
const alternates = alternatesOf.get(post);
|
|
2640
|
+
return alternates ? {
|
|
2641
|
+
...post,
|
|
2642
|
+
alternates
|
|
2643
|
+
} : post;
|
|
2644
|
+
});
|
|
2645
|
+
return {
|
|
2646
|
+
content: {
|
|
2647
|
+
...content,
|
|
2648
|
+
...content.posts ? { posts: mark(content.posts) } : {},
|
|
2649
|
+
...content.collections ? { collections: Object.fromEntries(Object.entries(content.collections).map(([name, posts]) => [name, mark(posts)])) } : {}
|
|
2650
|
+
},
|
|
2651
|
+
warnings
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
//#endregion
|
|
1922
2655
|
//#region src/css.ts
|
|
1923
2656
|
const LEADING = /^(\s*(?:@charset[^;]+;|@import[^;]+;|\/\*[\s\S]*?\*\/)\s*)+/;
|
|
1924
2657
|
function wrapLegacyCss(path, css) {
|
|
@@ -1931,6 +2664,13 @@ function wrapLegacyCss(path, css) {
|
|
|
1931
2664
|
}
|
|
1932
2665
|
//#endregion
|
|
1933
2666
|
//#region src/index.ts
|
|
2667
|
+
/**
|
|
2668
|
+
* A supplied trail the build will not print — the same rule as \`validTrail\`
|
|
2669
|
+
* in the emitted runtime, which a test holds this to.
|
|
2670
|
+
*/
|
|
2671
|
+
function badTrail(trail) {
|
|
2672
|
+
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
|
+
}
|
|
1934
2674
|
function emitAstroProject(input) {
|
|
1935
2675
|
const { ir } = input;
|
|
1936
2676
|
const warnings = [];
|
|
@@ -1950,6 +2690,7 @@ function emitAstroProject(input) {
|
|
|
1950
2690
|
add({ "src/components/Seo.astro": SEO_COMPONENT });
|
|
1951
2691
|
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");
|
|
1952
2692
|
}
|
|
2693
|
+
if (input.options?.sitemap !== false && !ir.site.url) warnings.push("site.url is empty — no sitemap is generated and robots.txt names none; set it so search engines can find every page");
|
|
1953
2694
|
const familiesById = new Map(ir.families.map((f) => [f.id, f]));
|
|
1954
2695
|
const lang = ir.site.locales?.[0] ?? "en";
|
|
1955
2696
|
const chrome = chromeComponents(ir.families);
|
|
@@ -1964,10 +2705,26 @@ function emitAstroProject(input) {
|
|
|
1964
2705
|
for (const post of posts) for (const id of componentMarkers(post.body)) ids.add(id);
|
|
1965
2706
|
if (ids.size) bodyMarkersByFamily.set(route.family, ids);
|
|
1966
2707
|
}
|
|
2708
|
+
const boundQueries = /* @__PURE__ */ new Set();
|
|
2709
|
+
const queryBound = /* @__PURE__ */ new Set();
|
|
2710
|
+
for (const family of ir.families) for (const placement of family.components ?? []) {
|
|
2711
|
+
if (!placement.query) continue;
|
|
2712
|
+
const pages = input.content?.queries?.[placement.query];
|
|
2713
|
+
if (!pages) {
|
|
2714
|
+
warnings.push(`family ${family.id}: placement "${placement.component}" binds query "${placement.query}", which is not in input.content.queries — binding dropped, region left as cloned markup`);
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
if (pages.length !== 1) warnings.push(`family ${family.id}: placement "${placement.component}" binds query "${placement.query}", which has ${pages.length} result sets — a page region has no route parameter to choose between them, so the build stops rather than rendering one of them everywhere`);
|
|
2718
|
+
else if (!pages[0].item_template && !pages[0].sections?.length) warnings.push(`family ${family.id}: placement "${placement.component}" binds query "${placement.query}", which has no item_template or sections — plain fallback list rendered (not fidelity)`);
|
|
2719
|
+
add({ [`src/data/queries/${placement.query}.json`]: stableJson(pages) });
|
|
2720
|
+
boundQueries.add(placement.query);
|
|
2721
|
+
queryBound.add(placement.component);
|
|
2722
|
+
}
|
|
1967
2723
|
for (const family of ir.families) {
|
|
1968
2724
|
const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions, bodyMarkersByFamily.get(family.id) ?? [], {
|
|
1969
2725
|
seo,
|
|
1970
|
-
siteName: ir.site.title
|
|
2726
|
+
siteName: ir.site.title,
|
|
2727
|
+
boundQueries
|
|
1971
2728
|
});
|
|
1972
2729
|
add(fam.files);
|
|
1973
2730
|
warnings.push(...fam.warnings);
|
|
@@ -1985,10 +2742,37 @@ function emitAstroProject(input) {
|
|
|
1985
2742
|
const allPosts = [...input.content?.posts ?? [], ...Object.values(input.content?.collections ?? {}).flat()];
|
|
1986
2743
|
for (const post of allPosts) missingCss(post.css, `post ${post.slug}`);
|
|
1987
2744
|
for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`);
|
|
2745
|
+
const trailOwners = [...Object.entries(input.content?.collections ?? {}), ...input.content?.posts ? [[DEFAULT_COLLECTION, input.content.posts]] : []];
|
|
2746
|
+
for (const [name, posts] of trailOwners) {
|
|
2747
|
+
const bad = (posts ?? []).filter((p) => badTrail(p.breadcrumbs)).length;
|
|
2748
|
+
if (bad) warnings.push(`collection ${name}: ${bad} breadcrumb trails dropped — every crumb needs a name and a site-root-relative path`);
|
|
2749
|
+
}
|
|
2750
|
+
for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) {
|
|
2751
|
+
const bad = queryPages.filter((qp) => badTrail(qp.breadcrumbs)).length;
|
|
2752
|
+
if (bad) warnings.push(`query ${queryId}: ${bad} breadcrumb trails dropped — every crumb needs a name and a site-root-relative path`);
|
|
2753
|
+
}
|
|
2754
|
+
let routeContent = input.content ?? {};
|
|
2755
|
+
if (seo) {
|
|
2756
|
+
const alternates = withAlternates(ir.routes, routeContent, lang);
|
|
2757
|
+
routeContent = alternates.content;
|
|
2758
|
+
warnings.push(...alternates.warnings);
|
|
2759
|
+
}
|
|
2760
|
+
const routeGroups = /* @__PURE__ */ new Map();
|
|
2761
|
+
for (const route of ir.routes) {
|
|
2762
|
+
const path = patternToPagePath(route.pattern);
|
|
2763
|
+
if (path) routeGroups.set(path, [...routeGroups.get(path) ?? [], route]);
|
|
2764
|
+
}
|
|
2765
|
+
const sharedPages = /* @__PURE__ */ new Map();
|
|
2766
|
+
for (const [path, group] of routeGroups) {
|
|
2767
|
+
if (!group.every((route) => familiesById.has(route.family))) continue;
|
|
2768
|
+
const page = sharedCollectionPage(group, routeContent, lang, seo);
|
|
2769
|
+
if (page) sharedPages.set(`src/pages/${path}`, page);
|
|
2770
|
+
}
|
|
1988
2771
|
const pageOwner = /* @__PURE__ */ new Map();
|
|
1989
2772
|
const collisions = [];
|
|
1990
2773
|
for (const route of ir.routes) {
|
|
1991
|
-
const result = routeFiles(route, familiesById.get(route.family),
|
|
2774
|
+
const result = routeFiles(route, familiesById.get(route.family), routeContent, lang, seo);
|
|
2775
|
+
for (const path of sharedPages.keys()) delete result.files[path];
|
|
1992
2776
|
for (const path of Object.keys(result.files)) {
|
|
1993
2777
|
if (!path.startsWith("src/pages/")) continue;
|
|
1994
2778
|
const owner = pageOwner.get(path);
|
|
@@ -2005,13 +2789,22 @@ function emitAstroProject(input) {
|
|
|
2005
2789
|
add(result.files);
|
|
2006
2790
|
warnings.push(...result.warnings);
|
|
2007
2791
|
}
|
|
2792
|
+
for (const [path, page] of sharedPages) files[path] = page;
|
|
2008
2793
|
for (const { path, first, second } of collisions) {
|
|
2009
2794
|
warnings.push(`route ${second.id}: pattern "${second.pattern}" emits the same Astro page as route ${first.id} ("${first.pattern}") — ${path}. Every page of "${second.id}" would be dropped, so the page now fails the build instead. Give one route a distinct pattern, or expand the narrower one into literal routes.`);
|
|
2010
2795
|
files[path] = collisionPage(path, first, second);
|
|
2011
2796
|
}
|
|
2012
|
-
const
|
|
2797
|
+
const stringsDir = uiStringsDir(input.options?.uiStrings?.dir);
|
|
2798
|
+
if (stringsDir === void 0) warnings.push(`options.uiStrings.dir "${input.options?.uiStrings?.dir}" is not a project-relative directory — using ${UI_STRINGS_DIR}`);
|
|
2799
|
+
const components = componentFiles(ir.components ?? [], input.runtime, queryBound, stringsDir ?? ".contentrain/content/site/ui-strings");
|
|
2013
2800
|
add(components.files);
|
|
2014
2801
|
warnings.push(...components.warnings);
|
|
2802
|
+
if (components.files["src/lib/ui-strings.ts"]) {
|
|
2803
|
+
const pageLangs = [...new Set([lang, ...ir.routes.map((r) => r.locale).filter((l) => Boolean(l))])].filter((l) => !l.toLowerCase().startsWith("en"));
|
|
2804
|
+
const declared = input.options?.uiStrings?.locales;
|
|
2805
|
+
const uncovered = declared ? pageLangs.filter((l) => !declared.includes(l) && !declared.includes(l.split("-")[0])) : pageLangs;
|
|
2806
|
+
if (uncovered.length) warnings.push(`ui-strings: no dictionary ${declared ? "declared" : "declared in options.uiStrings.locales"} for ${uncovered.join(", ")} — comments and forms on those pages show English text unless ${stringsDir ?? ".contentrain/content/site/ui-strings"}/{locale}.json exists at build`);
|
|
2807
|
+
}
|
|
2015
2808
|
if ((ir.components ?? []).some((c) => isRuntimeImplemented(c, input.runtime) && c.type === "comments")) {
|
|
2016
2809
|
const collections = { ...input.content?.collections };
|
|
2017
2810
|
if (input.content?.posts && !collections["posts"]) collections[DEFAULT_COLLECTION] = input.content.posts;
|
|
@@ -2045,6 +2838,6 @@ async function writeEmit(result, dir) {
|
|
|
2045
2838
|
await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
|
|
2046
2839
|
}
|
|
2047
2840
|
//#endregion
|
|
2048
|
-
export { EMBED_TS, RUNTIME_IMPLEMENTED, SEO_COMPONENT, balanceWarning, bodySeoLeaks, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, stripSeoTags, wrapLegacyCss, writeEmit };
|
|
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 };
|
|
2049
2842
|
|
|
2050
2843
|
//# sourceMappingURL=index.mjs.map
|