@contentrain/emitter-astro 0.13.0 → 0.14.1
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 +135 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +793 -60
- 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';`, ``];
|
|
@@ -274,6 +303,10 @@ export function renderSections<T>(
|
|
|
274
303
|
* region has no route to distinguish them, so exactly one is expected and
|
|
275
304
|
* anything else is a build error rather than a guess: picking the first would
|
|
276
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.
|
|
277
310
|
*/
|
|
278
311
|
export function renderQuery(pages: EmittedQueryPage[], id: string): string {
|
|
279
312
|
if (pages.length !== 1) {
|
|
@@ -282,10 +315,22 @@ export function renderQuery(pages: EmittedQueryPage[], id: string): string {
|
|
|
282
315
|
+ pages.length + '. A region has no route parameter to choose between them.',
|
|
283
316
|
)
|
|
284
317
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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>'
|
|
289
334
|
}
|
|
290
335
|
|
|
291
336
|
// ─── SEO helpers ───
|
|
@@ -323,6 +368,23 @@ export function absoluteUrl(value: string | undefined, site: URL | undefined): s
|
|
|
323
368
|
}
|
|
324
369
|
}
|
|
325
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
|
+
|
|
326
388
|
/**
|
|
327
389
|
* JSON-LD payload for a \`<script type="application/ld+json">\`. \`<\` is escaped
|
|
328
390
|
* so a value containing \`<\/script>\` cannot close the block and inject markup.
|
|
@@ -331,6 +393,102 @@ export function jsonLd(value: unknown): string {
|
|
|
331
393
|
return JSON.stringify(value).replace(/</g, '\\\\u003c')
|
|
332
394
|
}
|
|
333
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
|
+
|
|
334
492
|
/** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */
|
|
335
493
|
export const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)
|
|
336
494
|
|
|
@@ -412,6 +570,10 @@ export interface SeoInput {
|
|
|
412
570
|
description?: string
|
|
413
571
|
canonical?: string
|
|
414
572
|
image?: string
|
|
573
|
+
imageMeta?: ImageMeta
|
|
574
|
+
alternates?: Array<{ lang: string; path: string }>
|
|
575
|
+
breadcrumbs?: Breadcrumb[]
|
|
576
|
+
pageType?: 'WebPage' | 'CollectionPage'
|
|
415
577
|
type?: 'article' | 'website'
|
|
416
578
|
publishedAt?: string
|
|
417
579
|
modifiedAt?: string
|
|
@@ -431,6 +593,10 @@ export function postSeo(post: EmittedPost): SeoInput {
|
|
|
431
593
|
description: post.description ?? post.excerpt,
|
|
432
594
|
canonical: post.canonical,
|
|
433
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,
|
|
434
600
|
type: 'article',
|
|
435
601
|
publishedAt: post.published_at,
|
|
436
602
|
modifiedAt: post.modified_at,
|
|
@@ -444,6 +610,11 @@ export interface EmittedPost extends MarkablePost {
|
|
|
444
610
|
description?: string
|
|
445
611
|
/** Social image, absolute or site-root-relative. */
|
|
446
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[]
|
|
447
618
|
/** Canonical override; default is the page's own address. */
|
|
448
619
|
canonical?: string
|
|
449
620
|
/** ISO 8601, for Article structured data. */
|
|
@@ -469,7 +640,9 @@ export interface EmittedQueryPage {
|
|
|
469
640
|
title?: string
|
|
470
641
|
description?: string
|
|
471
642
|
image?: string
|
|
643
|
+
image_meta?: ImageMeta
|
|
472
644
|
canonical?: string
|
|
645
|
+
breadcrumbs?: Breadcrumb[]
|
|
473
646
|
}
|
|
474
647
|
`;
|
|
475
648
|
//#endregion
|
|
@@ -593,36 +766,173 @@ function balanceWarning(html) {
|
|
|
593
766
|
const TITLE_RE = /<title\b[^>]*>[\s\S]*?<\/title>\s*/gi;
|
|
594
767
|
const META_RE = /<meta\b[^>]*\b(?:name|property)\s*=\s*["'](?:description|og:[^"']*|twitter:[^"']*)["'][^>]*>\s*/gi;
|
|
595
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;
|
|
596
774
|
const JSONLD_RE = /<script\b[^>]*\btype\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>\s*/gi;
|
|
597
775
|
/**
|
|
598
|
-
* Structured
|
|
599
|
-
*
|
|
600
|
-
*
|
|
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.
|
|
601
781
|
*/
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
"
|
|
605
|
-
|
|
606
|
-
|
|
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([
|
|
607
787
|
"blogposting",
|
|
608
|
-
"
|
|
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"
|
|
609
811
|
]);
|
|
610
|
-
|
|
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) {
|
|
611
857
|
let parsed;
|
|
612
858
|
try {
|
|
613
859
|
parsed = JSON.parse(json);
|
|
614
860
|
} catch {
|
|
615
|
-
return
|
|
861
|
+
return {
|
|
862
|
+
json: void 0,
|
|
863
|
+
kept: [],
|
|
864
|
+
droppedSearch: false
|
|
865
|
+
};
|
|
616
866
|
}
|
|
617
867
|
const graph = parsed?.["@graph"];
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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;
|
|
621
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
|
+
}
|
|
622
931
|
}
|
|
623
932
|
/** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */
|
|
624
933
|
function stripSeoTags(html) {
|
|
625
934
|
const removed = [];
|
|
935
|
+
const kept = [];
|
|
626
936
|
const note = (label) => {
|
|
627
937
|
if (!removed.includes(label)) removed.push(label);
|
|
628
938
|
};
|
|
@@ -639,14 +949,23 @@ function stripSeoTags(html) {
|
|
|
639
949
|
note("canonical");
|
|
640
950
|
return "";
|
|
641
951
|
});
|
|
952
|
+
out = out.replace(HREFLANG_RE, () => {
|
|
953
|
+
note("hreflang");
|
|
954
|
+
return "";
|
|
955
|
+
});
|
|
642
956
|
out = out.replace(JSONLD_RE, (tag, json) => {
|
|
643
|
-
|
|
957
|
+
const decision = decideLd(json);
|
|
958
|
+
if (decision.json === void 0) return tag;
|
|
644
959
|
note("page-scoped JSON-LD");
|
|
645
|
-
|
|
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`;
|
|
646
964
|
});
|
|
647
965
|
return {
|
|
648
966
|
html: out,
|
|
649
|
-
removed
|
|
967
|
+
removed,
|
|
968
|
+
kept
|
|
650
969
|
};
|
|
651
970
|
}
|
|
652
971
|
/**
|
|
@@ -682,7 +1001,7 @@ const SEO_COMPONENT = `---
|
|
|
682
1001
|
* Canonical and absolute URLs need \`site\` in astro.config.mjs; without it the
|
|
683
1002
|
* canonical link and og:url are omitted rather than pointing at a build host.
|
|
684
1003
|
*/
|
|
685
|
-
import { absoluteUrl, jsonLd, seoDescription } from '../lib/fill'
|
|
1004
|
+
import { absoluteUrl, imageMetaTags, jsonLd, pageStructuredData, seoDescription, type Breadcrumb, type ImageMeta } from '../lib/fill'
|
|
686
1005
|
|
|
687
1006
|
interface Props {
|
|
688
1007
|
title?: string
|
|
@@ -692,6 +1011,16 @@ interface Props {
|
|
|
692
1011
|
canonical?: string
|
|
693
1012
|
/** Social image: absolute, or site-root-relative. */
|
|
694
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
|
|
695
1024
|
/** \`article\` on entry pages, \`website\` on lists and static pages. */
|
|
696
1025
|
type?: 'article' | 'website'
|
|
697
1026
|
/** ISO 8601 — structured data only; the displayed date stays a mark. */
|
|
@@ -707,6 +1036,11 @@ const {
|
|
|
707
1036
|
description,
|
|
708
1037
|
canonical,
|
|
709
1038
|
image,
|
|
1039
|
+
imageMeta,
|
|
1040
|
+
alternates = [],
|
|
1041
|
+
breadcrumbs,
|
|
1042
|
+
pageType,
|
|
1043
|
+
websiteId,
|
|
710
1044
|
type = 'website',
|
|
711
1045
|
publishedAt,
|
|
712
1046
|
modifiedAt,
|
|
@@ -718,33 +1052,47 @@ const {
|
|
|
718
1052
|
const site = Astro.site
|
|
719
1053
|
const url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(Astro.url.pathname, site)
|
|
720
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)
|
|
721
1062
|
const desc = seoDescription(description)
|
|
722
1063
|
const article = type === 'article'
|
|
723
|
-
const structured =
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
+
})
|
|
737
1080
|
---
|
|
738
1081
|
<title>{title}</title>
|
|
739
1082
|
{desc && <meta name="description" content={desc} />}
|
|
740
1083
|
{url && <link rel="canonical" href={url} />}
|
|
1084
|
+
{hreflang.map((a) => <link rel="alternate" hreflang={a.lang} href={a.href} />)}
|
|
741
1085
|
<meta property="og:type" content={type} />
|
|
742
1086
|
{title && <meta property="og:title" content={title} />}
|
|
743
1087
|
{desc && <meta property="og:description" content={desc} />}
|
|
744
1088
|
{url && <meta property="og:url" content={url} />}
|
|
745
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} />}
|
|
746
1093
|
{siteName && <meta property="og:site_name" content={siteName} />}
|
|
747
1094
|
{locale && <meta property="og:locale" content={locale} />}
|
|
1095
|
+
{localeAlternates.map((l) => <meta property="og:locale:alternate" content={l} />)}
|
|
748
1096
|
{article && publishedAt && <meta property="article:published_time" content={publishedAt} />}
|
|
749
1097
|
{article && modifiedAt && <meta property="article:modified_time" content={modifiedAt} />}
|
|
750
1098
|
{article && author && <meta property="article:author" content={author} />}
|
|
@@ -825,7 +1173,10 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
|
|
|
825
1173
|
if (seoOn) {
|
|
826
1174
|
const stripped = stripSeoTags(head);
|
|
827
1175
|
head = stripped.html;
|
|
828
|
-
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
|
+
}
|
|
829
1180
|
}
|
|
830
1181
|
const files = {};
|
|
831
1182
|
files[`src/data/chrome/${family.id}.json`] = stableJson({
|
|
@@ -836,6 +1187,8 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
|
|
|
836
1187
|
});
|
|
837
1188
|
const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
|
|
838
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)}}` : "";
|
|
839
1192
|
const imported = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])];
|
|
840
1193
|
const componentImports = [...seoOn ? [`import Seo from '../components/Seo.astro'`] : [], ...imported.map((n) => `import ${n} from '../components/${n}.astro'`)].join("\n");
|
|
841
1194
|
const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(" | ");
|
|
@@ -855,7 +1208,7 @@ const parts = splitComponents(html).map((part) => ({
|
|
|
855
1208
|
listHtml: part.component ? mounts[part.component]?.html : undefined,
|
|
856
1209
|
}))
|
|
857
1210
|
` : "";
|
|
858
|
-
const bodyRender = mounts.length ? ` {parts.map(({ html: part, Mount, variant, listHtml }) => (Mount ? <Mount entry={entry} variant={variant} html={listHtml} /> : <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} />`;
|
|
859
1212
|
const renderRefs = (position) => components.filter((c) => c.position === position).map((c) => ` <${c.name} marks={marks} />`).join("\n");
|
|
860
1213
|
files[`src/layouts/${name}.astro`] = `---
|
|
861
1214
|
// Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
|
|
@@ -898,7 +1251,7 @@ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
|
|
|
898
1251
|
<link rel="stylesheet" href={cssHref(file)} />
|
|
899
1252
|
))}
|
|
900
1253
|
<Fragment set:html={head} />
|
|
901
|
-
${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
|
|
1254
|
+
${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp}${websiteIdProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
|
|
902
1255
|
</head>
|
|
903
1256
|
<body {...bodyAttrs}>
|
|
904
1257
|
${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
|
|
@@ -1013,18 +1366,13 @@ const { page } = Astro.props as Props` : `const page = (data as EmittedQueryPage
|
|
|
1013
1366
|
// Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
|
|
1014
1367
|
import Layout from '${up}layouts/${layout}.astro'
|
|
1015
1368
|
import data from '${up}data/queries/${route.query}.json'
|
|
1016
|
-
import {
|
|
1369
|
+
import { renderQueryPage, type EmittedQueryPage } from '${up}lib/fill'
|
|
1017
1370
|
|
|
1018
1371
|
${paths}
|
|
1019
1372
|
// A list renders in sections (a big card then a grid) — one template is the
|
|
1020
|
-
// single-section case. Without a template at all
|
|
1373
|
+
// single-section case. Without a template at all it falls back to a plain list
|
|
1021
1374
|
// and the emitter has already warned: silence would read as fidelity.
|
|
1022
|
-
const
|
|
1023
|
-
const content = sections.length
|
|
1024
|
-
? renderSections(sections, page.items, postMarks)
|
|
1025
|
-
: '<ul class="cr-post-list">' +
|
|
1026
|
-
page.items.map((item) => '<li><a href="/' + item.slug + '/">' + esc(item.title) + '</a></li>').join('') +
|
|
1027
|
-
'</ul>'
|
|
1375
|
+
const content = renderQueryPage(page)
|
|
1028
1376
|
// Route params carry slugs; page marks carry what the chrome needs to SHOW
|
|
1029
1377
|
// (a term's display name, description, count).
|
|
1030
1378
|
const marks = { ...page.params, ...(page.marks ?? {}) }
|
|
@@ -1036,7 +1384,7 @@ const title = page.title ?? ${routeTitle}
|
|
|
1036
1384
|
body={content}
|
|
1037
1385
|
css={page.css ?? []}
|
|
1038
1386
|
lang={${locale}}${seo ? `
|
|
1039
|
-
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' }}` : ""}
|
|
1040
1388
|
/>
|
|
1041
1389
|
`;
|
|
1042
1390
|
}
|
|
@@ -1053,6 +1401,45 @@ ${paths}---
|
|
|
1053
1401
|
<Layout title=${JSON.stringify(route.title ?? "")} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}}${seo ? ` seo={{ type: 'website' }}` : ""} />
|
|
1054
1402
|
`;
|
|
1055
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
|
+
}
|
|
1056
1443
|
//#endregion
|
|
1057
1444
|
//#region src/embed.ts
|
|
1058
1445
|
const EMBED_TS = `// Emitted by @contentrain/emitter-astro — the runtime-component client.
|
|
@@ -1087,7 +1474,11 @@ export class EmbedError extends Error {
|
|
|
1087
1474
|
}
|
|
1088
1475
|
}
|
|
1089
1476
|
|
|
1090
|
-
/**
|
|
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
|
+
*/
|
|
1091
1482
|
export const strings = {
|
|
1092
1483
|
loading: 'Loading…',
|
|
1093
1484
|
send: 'Send',
|
|
@@ -1573,6 +1964,52 @@ export function resetCaptcha(root: ParentNode): void {
|
|
|
1573
1964
|
|
|
1574
1965
|
// ─── Mounting (the only DOM code) ───
|
|
1575
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
|
+
|
|
1576
2013
|
export function runtimeOf(host: HTMLElement): Runtime {
|
|
1577
2014
|
return { base_url: host.dataset.baseUrl ?? '', project_id: host.dataset.project ?? '' }
|
|
1578
2015
|
}
|
|
@@ -1606,6 +2043,7 @@ function entriesOf(form: HTMLFormElement): Array<[string, unknown]> {
|
|
|
1606
2043
|
|
|
1607
2044
|
/** \`<cr-form data-base-url data-project data-model>\` → fetch the config, render, submit. */
|
|
1608
2045
|
export async function mountForm(host: HTMLElement): Promise<void> {
|
|
2046
|
+
applyStrings(host)
|
|
1609
2047
|
const rt = runtimeOf(host)
|
|
1610
2048
|
const model = host.dataset.model ?? ''
|
|
1611
2049
|
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
@@ -1643,6 +2081,7 @@ export async function mountForm(host: HTMLElement): Promise<void> {
|
|
|
1643
2081
|
|
|
1644
2082
|
/** \`<cr-comments data-base-url data-project data-model data-entry data-locale>\` → thread + form. */
|
|
1645
2083
|
export async function mountComments(host: HTMLElement): Promise<void> {
|
|
2084
|
+
applyStrings(host)
|
|
1646
2085
|
const rt = runtimeOf(host)
|
|
1647
2086
|
const entry = entryOf(host)
|
|
1648
2087
|
host.innerHTML = '<p class="cr-loading">' + esc(strings.loading) + '</p>'
|
|
@@ -1749,6 +2188,110 @@ export async function mountComments(host: HTMLElement): Promise<void> {
|
|
|
1749
2188
|
}
|
|
1750
2189
|
`;
|
|
1751
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
|
|
1752
2295
|
//#region src/components.ts
|
|
1753
2296
|
/** Component types this emitter implements against a runtime binding. */
|
|
1754
2297
|
const RUNTIME_IMPLEMENTED = new Set(["comments", "form"]);
|
|
@@ -1756,7 +2299,7 @@ function isRuntimeImplemented(c, runtime) {
|
|
|
1756
2299
|
if (!runtime || c.source !== "runtime" || !RUNTIME_IMPLEMENTED.has(c.type)) return false;
|
|
1757
2300
|
return c.type !== "form" || Boolean(c.model);
|
|
1758
2301
|
}
|
|
1759
|
-
function componentFiles(components, runtime, queryBound = /* @__PURE__ */ new Set()) {
|
|
2302
|
+
function componentFiles(components, runtime, queryBound = /* @__PURE__ */ new Set(), uiStringsDir = ".contentrain/content/site/ui-strings") {
|
|
1760
2303
|
const files = {};
|
|
1761
2304
|
const warnings = [];
|
|
1762
2305
|
let needsRuntime = false;
|
|
@@ -1776,6 +2319,7 @@ function componentFiles(components, runtime, queryBound = /* @__PURE__ */ new Se
|
|
|
1776
2319
|
project_id: runtime.project_id
|
|
1777
2320
|
});
|
|
1778
2321
|
files["src/lib/embed.ts"] = EMBED_TS;
|
|
2322
|
+
files["src/lib/ui-strings.ts"] = uiStringsSource(uiStringsDir);
|
|
1779
2323
|
}
|
|
1780
2324
|
return {
|
|
1781
2325
|
files,
|
|
@@ -1803,6 +2347,8 @@ interface Props {
|
|
|
1803
2347
|
* while staying current there.
|
|
1804
2348
|
*/
|
|
1805
2349
|
html?: string
|
|
2350
|
+
/** The page's language; unused by a placeholder. */
|
|
2351
|
+
lang?: string
|
|
1806
2352
|
}
|
|
1807
2353
|
const { variant = ${JSON.stringify(variants[0] ?? "default")}, html } = Astro.props
|
|
1808
2354
|
---
|
|
@@ -1819,13 +2365,19 @@ ${header(c, `
|
|
|
1819
2365
|
* moderation happens on the provider. Renders nothing on a page without an
|
|
1820
2366
|
* entry address (a list, a static page).`)}
|
|
1821
2367
|
import runtime from '../data/runtime.json'
|
|
2368
|
+
import { uiStrings } from '../lib/ui-strings'
|
|
1822
2369
|
|
|
1823
2370
|
interface Props {
|
|
1824
2371
|
/** Content-store address of the page's entry — the thread's key. */
|
|
1825
2372
|
entry?: { model_id: string; entry_id: string; locale?: string }
|
|
1826
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
|
|
1827
2378
|
}
|
|
1828
|
-
const { entry, variant = 'default' } = Astro.props
|
|
2379
|
+
const { entry, variant = 'default', lang } = Astro.props
|
|
2380
|
+
const text = uiStrings(lang)
|
|
1829
2381
|
---
|
|
1830
2382
|
{entry && (
|
|
1831
2383
|
<cr-comments
|
|
@@ -1836,8 +2388,9 @@ const { entry, variant = 'default' } = Astro.props
|
|
|
1836
2388
|
data-entry={entry.entry_id}
|
|
1837
2389
|
data-locale={entry.locale}
|
|
1838
2390
|
data-variant={variant}
|
|
2391
|
+
data-strings={JSON.stringify(text)}
|
|
1839
2392
|
>
|
|
1840
|
-
<noscript><p class="cr-noscript">
|
|
2393
|
+
<noscript><p class="cr-noscript">{text['comments.noscript']}</p></noscript>
|
|
1841
2394
|
</cr-comments>
|
|
1842
2395
|
)}
|
|
1843
2396
|
|
|
@@ -1862,13 +2415,19 @@ ${header(c, `
|
|
|
1862
2415
|
* Submissions land on the provider for moderation; an approved one becomes a
|
|
1863
2416
|
* content entry there.`)}
|
|
1864
2417
|
import runtime from '../data/runtime.json'
|
|
2418
|
+
import { uiStrings } from '../lib/ui-strings'
|
|
1865
2419
|
|
|
1866
2420
|
interface Props {
|
|
1867
2421
|
variant?: string
|
|
1868
2422
|
/** Accepted for uniform mounting; a form is addressed by its model, not by the page's entry. */
|
|
1869
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
|
|
1870
2428
|
}
|
|
1871
|
-
const { variant = 'default' } = Astro.props
|
|
2429
|
+
const { variant = 'default', lang } = Astro.props
|
|
2430
|
+
const text = uiStrings(lang)
|
|
1872
2431
|
---
|
|
1873
2432
|
<cr-form
|
|
1874
2433
|
class={\`cr-form-mount cr-form-mount--\${variant}\`}
|
|
@@ -1876,8 +2435,9 @@ const { variant = 'default' } = Astro.props
|
|
|
1876
2435
|
data-project={runtime.project_id}
|
|
1877
2436
|
data-model=${JSON.stringify(c.model)}
|
|
1878
2437
|
data-variant={variant}
|
|
2438
|
+
data-strings={JSON.stringify(text)}
|
|
1879
2439
|
>
|
|
1880
|
-
<noscript><p class="cr-noscript">
|
|
2440
|
+
<noscript><p class="cr-noscript">{text['form.noscript']}</p></noscript>
|
|
1881
2441
|
</cr-form>
|
|
1882
2442
|
|
|
1883
2443
|
<script>
|
|
@@ -1964,6 +2524,134 @@ function chromeComponents(families) {
|
|
|
1964
2524
|
};
|
|
1965
2525
|
}
|
|
1966
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
|
|
1967
2655
|
//#region src/css.ts
|
|
1968
2656
|
const LEADING = /^(\s*(?:@charset[^;]+;|@import[^;]+;|\/\*[\s\S]*?\*\/)\s*)+/;
|
|
1969
2657
|
function wrapLegacyCss(path, css) {
|
|
@@ -1976,6 +2664,13 @@ function wrapLegacyCss(path, css) {
|
|
|
1976
2664
|
}
|
|
1977
2665
|
//#endregion
|
|
1978
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
|
+
}
|
|
1979
2674
|
function emitAstroProject(input) {
|
|
1980
2675
|
const { ir } = input;
|
|
1981
2676
|
const warnings = [];
|
|
@@ -1995,6 +2690,7 @@ function emitAstroProject(input) {
|
|
|
1995
2690
|
add({ "src/components/Seo.astro": SEO_COMPONENT });
|
|
1996
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");
|
|
1997
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");
|
|
1998
2694
|
const familiesById = new Map(ir.families.map((f) => [f.id, f]));
|
|
1999
2695
|
const lang = ir.site.locales?.[0] ?? "en";
|
|
2000
2696
|
const chrome = chromeComponents(ir.families);
|
|
@@ -2019,6 +2715,7 @@ function emitAstroProject(input) {
|
|
|
2019
2715
|
continue;
|
|
2020
2716
|
}
|
|
2021
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)`);
|
|
2022
2719
|
add({ [`src/data/queries/${placement.query}.json`]: stableJson(pages) });
|
|
2023
2720
|
boundQueries.add(placement.query);
|
|
2024
2721
|
queryBound.add(placement.component);
|
|
@@ -2045,10 +2742,37 @@ function emitAstroProject(input) {
|
|
|
2045
2742
|
const allPosts = [...input.content?.posts ?? [], ...Object.values(input.content?.collections ?? {}).flat()];
|
|
2046
2743
|
for (const post of allPosts) missingCss(post.css, `post ${post.slug}`);
|
|
2047
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
|
+
}
|
|
2048
2771
|
const pageOwner = /* @__PURE__ */ new Map();
|
|
2049
2772
|
const collisions = [];
|
|
2050
2773
|
for (const route of ir.routes) {
|
|
2051
|
-
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];
|
|
2052
2776
|
for (const path of Object.keys(result.files)) {
|
|
2053
2777
|
if (!path.startsWith("src/pages/")) continue;
|
|
2054
2778
|
const owner = pageOwner.get(path);
|
|
@@ -2065,13 +2789,22 @@ function emitAstroProject(input) {
|
|
|
2065
2789
|
add(result.files);
|
|
2066
2790
|
warnings.push(...result.warnings);
|
|
2067
2791
|
}
|
|
2792
|
+
for (const [path, page] of sharedPages) files[path] = page;
|
|
2068
2793
|
for (const { path, first, second } of collisions) {
|
|
2069
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.`);
|
|
2070
2795
|
files[path] = collisionPage(path, first, second);
|
|
2071
2796
|
}
|
|
2072
|
-
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");
|
|
2073
2800
|
add(components.files);
|
|
2074
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
|
+
}
|
|
2075
2808
|
if ((ir.components ?? []).some((c) => isRuntimeImplemented(c, input.runtime) && c.type === "comments")) {
|
|
2076
2809
|
const collections = { ...input.content?.collections };
|
|
2077
2810
|
if (input.content?.posts && !collections["posts"]) collections[DEFAULT_COLLECTION] = input.content.posts;
|
|
@@ -2105,6 +2838,6 @@ async function writeEmit(result, dir) {
|
|
|
2105
2838
|
await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
|
|
2106
2839
|
}
|
|
2107
2840
|
//#endregion
|
|
2108
|
-
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 };
|
|
2109
2842
|
|
|
2110
2843
|
//# sourceMappingURL=index.mjs.map
|