@contentrain/emitter-astro 0.13.0 → 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/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
- const page = pages[0]!
286
- const sections = page.sections ?? (page.item_template ? [{ template: page.item_template }] : [])
287
- if (!sections.length) return ''
288
- return renderSections(sections, page.items, postMarks)
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-data types that describe THIS page. An Organization or WebSite
599
- * block is site-wide and true on every page; an Article block names one post
600
- * and is a lie on all the others.
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
- const PAGE_LD_TYPES = new Set([
603
- "article",
604
- "blogposting",
605
- "newsarticle",
606
- "webpage",
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
- "techarticle"
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
- function isPageScopedLd(json) {
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 false;
861
+ return {
862
+ json: void 0,
863
+ kept: [],
864
+ droppedSearch: false
865
+ };
616
866
  }
617
867
  const graph = parsed?.["@graph"];
618
- return (Array.isArray(parsed) ? parsed : Array.isArray(graph) ? graph : [parsed]).some((node) => {
619
- const type = node?.["@type"];
620
- return (Array.isArray(type) ? type : [type]).some((t) => typeof t === "string" && PAGE_LD_TYPES.has(t.toLowerCase()));
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
- if (!isPageScopedLd(json)) return tag;
957
+ const decision = decideLd(json);
958
+ if (decision.json === void 0) return tag;
644
959
  note("page-scoped JSON-LD");
645
- return "";
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 = article
724
- ? {
725
- '@context': 'https://schema.org',
726
- '@type': 'Article',
727
- headline: title,
728
- ...(desc ? { description: desc } : {}),
729
- ...(imageUrl ? { image: [imageUrl] } : {}),
730
- ...(publishedAt ? { datePublished: publishedAt } : {}),
731
- ...(modifiedAt ? { dateModified: modifiedAt } : {}),
732
- ...(author ? { author: { '@type': 'Person', name: author } } : {}),
733
- ...(url ? { mainEntityOfPage: { '@type': 'WebPage', '@id': url } } : {}),
734
- ...(siteName ? { publisher: { '@type': 'Organization', name: siteName } } : {}),
735
- }
736
- : undefined
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) warnings.push(`family ${family.id}: removed the template page's ${stripped.removed.join(", ")} from head chrome — the emitter renders these per page`);
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 { esc, postMarks, renderSections, type EmittedQueryPage } from '${up}lib/fill'
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 we fall back to a plain list
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 sections = page.sections ?? (page.item_template ? [{ template: page.item_template }] : [])
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
- /** Visitor-facing strings — override from the site (\`strings.send = 'Gönder'\`). */
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">Comments need JavaScript.</p></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">This form needs JavaScript.</p></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), input.content ?? {}, lang, seo);
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 components = componentFiles(ir.components ?? [], input.runtime, queryBound);
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