@contentrain/emitter-astro 0.12.1 → 0.14.0

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