@contentrain/emitter-astro 0.9.2 → 0.10.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
@@ -266,6 +266,49 @@ export function renderSections<T>(
266
266
  return out.join('')
267
267
  }
268
268
 
269
+ // ─── SEO helpers ───
270
+
271
+ /**
272
+ * A meta description from free text: markup stripped, whitespace collapsed,
273
+ * cut at a word boundary. The cap is a rendering limit, not an editorial
274
+ * choice — an excerpt is a paragraph and a description tag is a line.
275
+ */
276
+ export function seoDescription(text: string | undefined, max = 300): string | undefined {
277
+ if (!text) return undefined
278
+ const flat = text.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()
279
+ if (!flat) return undefined
280
+ if (flat.length <= max) return flat
281
+ const cut = flat.slice(0, max)
282
+ const space = cut.lastIndexOf(' ')
283
+ // Concatenation, not a template literal: this file is emitted from one, and
284
+ // an unescaped interpolation would be evaluated at emit time.
285
+ return (space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd() + '…'
286
+ }
287
+
288
+ /**
289
+ * Absolute URL for a social tag or a canonical link. Crawlers do not resolve
290
+ * relative og:image or og:url, so a value that cannot be made absolute is
291
+ * dropped rather than emitted half-formed. Needs \`site\` in astro.config.mjs.
292
+ */
293
+ export function absoluteUrl(value: string | undefined, site: URL | undefined): string | undefined {
294
+ if (!value) return undefined
295
+ if (/^https?:\\/\\//i.test(value)) return value
296
+ if (!site) return undefined
297
+ try {
298
+ return new URL(value, site).toString()
299
+ } catch {
300
+ return undefined
301
+ }
302
+ }
303
+
304
+ /**
305
+ * JSON-LD payload for a \`<script type="application/ld+json">\`. \`<\` is escaped
306
+ * so a value containing \`<\/script>\` cannot close the block and inject markup.
307
+ */
308
+ export function jsonLd(value: unknown): string {
309
+ return JSON.stringify(value).replace(/</g, '\\\\u003c')
310
+ }
311
+
269
312
  /** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */
270
313
  export const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)
271
314
 
@@ -342,8 +385,48 @@ export interface EntryRef {
342
385
  locale?: string
343
386
  }
344
387
 
388
+ export interface SeoInput {
389
+ title?: string
390
+ description?: string
391
+ canonical?: string
392
+ image?: string
393
+ type?: 'article' | 'website'
394
+ publishedAt?: string
395
+ modifiedAt?: string
396
+ author?: string
397
+ }
398
+
399
+ /**
400
+ * The SEO of one entry page. \`featured\` holds bare file names — only the
401
+ * producer knows where media is served from — so an image is taken from it
402
+ * only when it already reads as a path; otherwise \`image\` supplies it and
403
+ * the tag is omitted rather than pointing at a URL that does not resolve.
404
+ */
405
+ export function postSeo(post: EmittedPost): SeoInput {
406
+ const featured = (post.featured ?? []).find((f) => f.startsWith('/') || /^https?:/i.test(f))
407
+ return {
408
+ title: post.title,
409
+ description: post.description ?? post.excerpt,
410
+ canonical: post.canonical,
411
+ image: post.image ?? featured,
412
+ type: 'article',
413
+ publishedAt: post.published_at,
414
+ modifiedAt: post.modified_at,
415
+ author: post.author,
416
+ }
417
+ }
418
+
345
419
  export interface EmittedPost extends MarkablePost {
346
420
  body: string
421
+ /** Meta description; falls back to the excerpt. */
422
+ description?: string
423
+ /** Social image, absolute or site-root-relative. */
424
+ image?: string
425
+ /** Canonical override; default is the page's own address. */
426
+ canonical?: string
427
+ /** ISO 8601, for Article structured data. */
428
+ published_at?: string
429
+ modified_at?: string
347
430
  /** Route parameters beyond slug — the date parts of a dated permalink, a post id. */
348
431
  params?: Record<string, string>
349
432
  /** Stylesheets only this page loads. */
@@ -362,6 +445,9 @@ export interface EmittedQueryPage {
362
445
  item_template?: string
363
446
  sections?: ListSection[]
364
447
  title?: string
448
+ description?: string
449
+ image?: string
450
+ canonical?: string
365
451
  }
366
452
  `;
367
453
  //#endregion
@@ -480,6 +566,173 @@ function balanceWarning(html) {
480
566
  return parts.length ? parts.join("; ") : null;
481
567
  }
482
568
  //#endregion
569
+ //#region src/seo.ts
570
+ /** Tags the emitter owns; a source copy of any of these is replaced, not duplicated. */
571
+ const TITLE_RE = /<title\b[^>]*>[\s\S]*?<\/title>\s*/gi;
572
+ const META_RE = /<meta\b[^>]*\b(?:name|property)\s*=\s*["'](?:description|og:[^"']*|twitter:[^"']*)["'][^>]*>\s*/gi;
573
+ const CANONICAL_RE = /<link\b[^>]*\brel\s*=\s*["'][^"']*\bcanonical\b[^"']*["'][^>]*>\s*/gi;
574
+ const JSONLD_RE = /<script\b[^>]*\btype\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>\s*/gi;
575
+ /**
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.
579
+ */
580
+ const PAGE_LD_TYPES = new Set([
581
+ "article",
582
+ "blogposting",
583
+ "newsarticle",
584
+ "webpage",
585
+ "blogposting",
586
+ "techarticle"
587
+ ]);
588
+ function isPageScopedLd(json) {
589
+ let parsed;
590
+ try {
591
+ parsed = JSON.parse(json);
592
+ } catch {
593
+ return false;
594
+ }
595
+ 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()));
599
+ });
600
+ }
601
+ /** Remove the source head's per-page SEO tags so the emitter's own are the only ones. */
602
+ function stripSeoTags(html) {
603
+ const removed = [];
604
+ const note = (label) => {
605
+ if (!removed.includes(label)) removed.push(label);
606
+ };
607
+ let out = html.replace(TITLE_RE, () => {
608
+ note("<title>");
609
+ return "";
610
+ });
611
+ out = out.replace(META_RE, (tag) => {
612
+ const name = /\b(?:name|property)\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1] ?? "meta";
613
+ note(name.toLowerCase().startsWith("og:") ? "og:*" : name.toLowerCase().startsWith("twitter:") ? "twitter:*" : name);
614
+ return "";
615
+ });
616
+ out = out.replace(CANONICAL_RE, () => {
617
+ note("canonical");
618
+ return "";
619
+ });
620
+ out = out.replace(JSONLD_RE, (tag, json) => {
621
+ if (!isPageScopedLd(json)) return tag;
622
+ note("page-scoped JSON-LD");
623
+ return "";
624
+ });
625
+ return {
626
+ html: out,
627
+ removed
628
+ };
629
+ }
630
+ /**
631
+ * Head-only tags found in BODY chrome. A browser that closed `<head>` early
632
+ * leaves the template's canonical and og tags in the body, and a faithful clone
633
+ * carries them there — so the page ends up with the emitter's correct tags AND
634
+ * the template's stale ones, which no fidelity score can see.
635
+ *
636
+ * They are reported, not removed: the body is page content, and `<title>` is
637
+ * legal inside `<svg>`, so cutting tags out of it would break real markup to
638
+ * fix an invisible one. Only unambiguous head tags are looked for.
639
+ */
640
+ function bodySeoLeaks(html) {
641
+ const found = [];
642
+ if (/<link\b[^>]*\brel\s*=\s*["'][^"']*\bcanonical\b/i.test(html)) found.push("canonical");
643
+ if (/<meta\b[^>]*\bproperty\s*=\s*["']og:/i.test(html)) found.push("og:*");
644
+ if (/<meta\b[^>]*\bname\s*=\s*["']description["']/i.test(html)) found.push("description");
645
+ return found;
646
+ }
647
+ /**
648
+ * `src/components/Seo.astro`. The canonical address comes from `Astro.url` and
649
+ * `Astro.site` rather than from data: it is then, by construction, the address
650
+ * Astro actually generated, and no producer has to recompute a permalink.
651
+ */
652
+ const SEO_COMPONENT = `---
653
+ /**
654
+ * Per-page SEO — emitted by @contentrain/emitter-astro.
655
+ *
656
+ * Title, description, canonical, Open Graph, Twitter card and (on entry pages)
657
+ * Article structured data, derived from the entry. The source theme's copies of
658
+ * these tags are removed from the head chrome at emit time, so each appears once.
659
+ *
660
+ * Canonical and absolute URLs need \`site\` in astro.config.mjs; without it the
661
+ * canonical link and og:url are omitted rather than pointing at a build host.
662
+ */
663
+ import { absoluteUrl, jsonLd, seoDescription } from '../lib/fill'
664
+
665
+ interface Props {
666
+ title?: string
667
+ /** Meta description; falls back to the entry's excerpt, tags stripped. */
668
+ description?: string
669
+ /** Overrides the generated address — for a page that canonicalises elsewhere. */
670
+ canonical?: string
671
+ /** Social image: absolute, or site-root-relative. */
672
+ image?: string
673
+ /** \`article\` on entry pages, \`website\` on lists and static pages. */
674
+ type?: 'article' | 'website'
675
+ /** ISO 8601 — structured data only; the displayed date stays a mark. */
676
+ publishedAt?: string
677
+ modifiedAt?: string
678
+ author?: string
679
+ siteName?: string
680
+ locale?: string
681
+ }
682
+
683
+ const {
684
+ title = '',
685
+ description,
686
+ canonical,
687
+ image,
688
+ type = 'website',
689
+ publishedAt,
690
+ modifiedAt,
691
+ author,
692
+ siteName,
693
+ locale,
694
+ } = Astro.props
695
+
696
+ const site = Astro.site
697
+ const url = canonical ? absoluteUrl(canonical, site) : absoluteUrl(Astro.url.pathname, site)
698
+ const imageUrl = absoluteUrl(image, site)
699
+ const desc = seoDescription(description)
700
+ 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
715
+ ---
716
+ <title>{title}</title>
717
+ {desc && <meta name="description" content={desc} />}
718
+ {url && <link rel="canonical" href={url} />}
719
+ <meta property="og:type" content={type} />
720
+ {title && <meta property="og:title" content={title} />}
721
+ {desc && <meta property="og:description" content={desc} />}
722
+ {url && <meta property="og:url" content={url} />}
723
+ {imageUrl && <meta property="og:image" content={imageUrl} />}
724
+ {siteName && <meta property="og:site_name" content={siteName} />}
725
+ {locale && <meta property="og:locale" content={locale} />}
726
+ {article && publishedAt && <meta property="article:published_time" content={publishedAt} />}
727
+ {article && modifiedAt && <meta property="article:modified_time" content={modifiedAt} />}
728
+ {article && author && <meta property="article:author" content={author} />}
729
+ <meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
730
+ {title && <meta name="twitter:title" content={title} />}
731
+ {desc && <meta name="twitter:description" content={desc} />}
732
+ {imageUrl && <meta name="twitter:image" content={imageUrl} />}
733
+ {structured && <script is:inline type="application/ld+json" set:html={jsonLd(structured)} />}
734
+ `;
735
+ //#endregion
483
736
  //#region src/layouts.ts
484
737
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
485
738
  const MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\s]+)${escapeRe(CHROME_COMPONENT_CLOSE)}`, "g");
@@ -487,10 +740,11 @@ const MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\s]+)${esca
487
740
  function componentMarkers(html) {
488
741
  return [...new Set([...html.matchAll(MARKER_RE)].map((m) => m[1] ?? ""))];
489
742
  }
490
- function familyFiles(family, lang, components = [], definitions = /* @__PURE__ */ new Map(), bodyMarkers = []) {
743
+ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ */ new Map(), bodyMarkers = [], options = {}) {
491
744
  const name = pascalCase(family.id);
492
745
  const chunks = (family.chrome ?? []).filter((c) => c.position !== "header" && c.position !== "footer");
493
746
  const warnings = [];
747
+ const seoOn = options.seo !== false;
494
748
  for (const chunk of family.chrome ?? []) if ((chunk.position === "header" || chunk.position === "footer") && componentMarkers(chunk.html).length) warnings.push(`family ${family.id}: ${chunk.position} chrome carries a component marker — components mount in body chrome only; the marker renders as a comment`);
495
749
  const joined = (position) => chunks.filter((c) => c.position === position).map((c) => c.html).join("\n");
496
750
  const bodyChunks = chunks.filter((c) => c.position === "body");
@@ -502,6 +756,10 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
502
756
  body += CHROME_BODY_SLOT;
503
757
  }
504
758
  } else body = `${joined("before_body")}${CHROME_BODY_SLOT}${joined("after_body")}`;
759
+ if (seoOn) {
760
+ const leaked = bodySeoLeaks(body);
761
+ if (leaked.length) warnings.push(`family ${family.id}: body chrome carries head-only ${leaked.join(", ")} — a page then serves the template's copy alongside the emitter's; lift them into the head chunk or drop them at capture`);
762
+ }
505
763
  const unbalanced = balanceWarning(body);
506
764
  if (unbalanced) warnings.push(`family ${family.id}: body chrome is not balanced (${unbalanced}) — the browser will repair it and the page loses its layout`);
507
765
  const placements = new Map((family.components ?? []).map((p) => [p.component, p]));
@@ -533,15 +791,23 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
533
791
  mount(id, def);
534
792
  }
535
793
  for (const placement of family.components ?? []) if (!mounts.some((m) => m.id === placement.component)) warnings.push(`family ${family.id}: component placement "${placement.component}" has no marker in the body chrome or in any content body — not mounted`);
794
+ let head = joined("head");
795
+ if (seoOn) {
796
+ const stripped = stripSeoTags(head);
797
+ 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`);
799
+ }
536
800
  const files = {};
537
801
  files[`src/data/chrome/${family.id}.json`] = stableJson({
538
- head: joined("head"),
802
+ head,
539
803
  body,
540
804
  html_attrs: family.root_attrs?.html ?? {},
541
805
  body_attrs: family.root_attrs?.body ?? {}
542
806
  });
543
807
  const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
544
- const componentImports = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])].map((n) => `import ${n} from '../components/${n}.astro'`).join("\n");
808
+ const siteNameProp = options.siteName ? ` siteName={${JSON.stringify(options.siteName)}}` : "";
809
+ const imported = [...new Set([...components.map((c) => c.name), ...mounts.map((m) => m.name)])];
810
+ const componentImports = [...seoOn ? [`import Seo from '../components/Seo.astro'`] : [], ...imported.map((n) => `import ${n} from '../components/${n}.astro'`)].join("\n");
545
811
  const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(" | ");
546
812
  const mountTable = mounts.length ? `// Components the chrome mounts at <!--@@component:ID@@--> markers; the page's
547
813
  // entry address travels to each so a comments thread knows which entry it is.
@@ -559,7 +825,7 @@ const parts = splitComponents(html).map((part) => ({
559
825
  files[`src/layouts/${name}.astro`] = `---
560
826
  // Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
561
827
  import chrome from '../data/chrome/${family.id}.json'
562
- import { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ", splitComponents" : ""} } from '../lib/fill'
828
+ import { cssHref, fillAttrs, renderTemplate, composeBody${mounts.length ? ", splitComponents" : ""}${seoOn ? ", type SeoInput" : ""} } from '../lib/fill'
563
829
  ${componentImports ? `${componentImports}\n` : ""}
564
830
  interface Props {
565
831
  title?: string
@@ -571,9 +837,11 @@ interface Props {
571
837
  /** Document language — a multilingual site's routes each pass their own. */
572
838
  lang?: string
573
839
  /** Content-store address of the page's entry — mounted components (comments) key on it. */
574
- entry?: { model_id: string; entry_id: string; locale?: string }
840
+ entry?: { model_id: string; entry_id: string; locale?: string }${seoOn ? `
841
+ /** Per-page SEO — see src/components/Seo.astro. */
842
+ seo?: SeoInput` : ""}
575
843
  }
576
- const { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)}${mounts.length ? ", entry" : ""} } = Astro.props
844
+ const { title = '', marks = {}, body, css = [], lang = ${JSON.stringify(lang)}${mounts.length ? ", entry" : ""}${seoOn ? ", seo" : ""} } = Astro.props
577
845
  const head = renderTemplate(chrome.head, marks)
578
846
  // Root attributes carry the theme's layout hooks; values may hold @@marks@@.
579
847
  // An explicit lang from the source wins over the project default.
@@ -594,7 +862,7 @@ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
594
862
  <link rel="stylesheet" href={cssHref(file)} />
595
863
  ))}
596
864
  <Fragment set:html={head} />
597
- <title>{title}</title>
865
+ ${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
598
866
  </head>
599
867
  <body {...bodyAttrs}>
600
868
  ${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
@@ -608,7 +876,7 @@ ${renderRefs("footer") ? `${renderRefs("footer")}\n` : ""} </body>
608
876
  }
609
877
  //#endregion
610
878
  //#region src/pages.ts
611
- function routeFiles(route, family, content, siteLocale) {
879
+ function routeFiles(route, family, content, siteLocale, seo = true) {
612
880
  const warnings = [];
613
881
  const files = {};
614
882
  const pagePath = patternToPagePath(route.pattern);
@@ -631,7 +899,7 @@ function routeFiles(route, family, content, siteLocale) {
631
899
  const posts = collectionItems(content, collection);
632
900
  if (!posts.length) warnings.push(`route ${route.id}: collection "${collection}" has no items — page emitted, data file empty`);
633
901
  files[`src/data/${collection}.json`] = stableJson(posts);
634
- files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale, hasParams);
902
+ files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale, hasParams, seo);
635
903
  return {
636
904
  files,
637
905
  warnings
@@ -642,13 +910,13 @@ function routeFiles(route, family, content, siteLocale) {
642
910
  if (!queryPages.length) warnings.push(`route ${route.id}: query "${route.query}" has no data — page emitted, data file empty`);
643
911
  if (queryPages.some((p) => !p.item_template && !p.sections?.length)) warnings.push(`route ${route.id}: item template missing on some pages — plain fallback list rendered (not fidelity)`);
644
912
  files[`src/data/queries/${route.query}.json`] = stableJson(queryPages);
645
- files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale);
913
+ files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale, seo);
646
914
  return {
647
915
  files,
648
916
  warnings
649
917
  };
650
918
  }
651
- files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale);
919
+ files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale, seo);
652
920
  return {
653
921
  files,
654
922
  warnings
@@ -660,13 +928,13 @@ function collectionItems(content, collection) {
660
928
  if (named) return named;
661
929
  return collection === "posts" ? content.posts ?? [] : [];
662
930
  }
663
- function singlePage(route, layout, up, collection, siteLocale, hasParams) {
931
+ function singlePage(route, layout, up, collection, siteLocale, hasParams, seo) {
664
932
  const locale = JSON.stringify(route.locale ?? siteLocale);
665
933
  return `---
666
934
  // Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro
667
935
  import Layout from '${up}layouts/${layout}.astro'
668
936
  import data from '${up}data/${collection}.json'
669
- import { postMarks, type EmittedPost } from '${up}lib/fill'
937
+ import { postMarks${seo ? ", postSeo" : ""}, type EmittedPost } from '${up}lib/fill'
670
938
 
671
939
  ${hasParams ? `export function getStaticPaths() {
672
940
  // The JSON is data; its contract is EmittedPost. Inferring the type from the
@@ -691,11 +959,12 @@ const post = posts[0]!`}
691
959
  body={post.body}
692
960
  css={post.css ?? []}
693
961
  lang={post.locale ?? ${locale}}
694
- entry={post.entry}
962
+ entry={post.entry}${seo ? `
963
+ seo={postSeo(post)}` : ""}
695
964
  />
696
965
  `;
697
966
  }
698
- function listPage(route, layout, up, hasParams, siteLocale) {
967
+ function listPage(route, layout, up, hasParams, siteLocale, seo) {
699
968
  const locale = JSON.stringify(route.locale ?? siteLocale);
700
969
  const routeTitle = route.title === void 0 ? `''` : JSON.stringify(route.title);
701
970
  const paths = hasParams ? `export function getStaticPaths() {
@@ -730,11 +999,12 @@ const title = page.title ?? ${routeTitle}
730
999
  marks={marks}
731
1000
  body={content}
732
1001
  css={page.css ?? []}
733
- lang={${locale}}
1002
+ lang={${locale}}${seo ? `
1003
+ seo={{ description: page.description, image: page.image, canonical: page.canonical, type: 'website' }}` : ""}
734
1004
  />
735
1005
  `;
736
1006
  }
737
- function staticPage(route, layout, up, hasParams, warnings, siteLocale) {
1007
+ function staticPage(route, layout, up, hasParams, warnings, siteLocale, seo) {
738
1008
  if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`);
739
1009
  const paths = hasParams ? `export function getStaticPaths() {
740
1010
  return []
@@ -744,7 +1014,7 @@ function staticPage(route, layout, up, hasParams, warnings, siteLocale) {
744
1014
  // Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
745
1015
  import Layout from '${up}layouts/${layout}.astro'
746
1016
  ${paths}---
747
- <Layout title=${JSON.stringify(route.title ?? "")} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}} />
1017
+ <Layout title=${JSON.stringify(route.title ?? "")} marks={{}} lang={${JSON.stringify(route.locale ?? siteLocale)}}${seo ? ` seo={{ type: 'website' }}` : ""} />
748
1018
  `;
749
1019
  }
750
1020
  //#endregion
@@ -1675,6 +1945,11 @@ function emitAstroProject(input) {
1675
1945
  }
1676
1946
  };
1677
1947
  add(scaffoldFiles(ir, input.options ?? {}));
1948
+ const seo = input.options?.seo !== false;
1949
+ if (seo) {
1950
+ add({ "src/components/Seo.astro": SEO_COMPONENT });
1951
+ 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
+ }
1678
1953
  const familiesById = new Map(ir.families.map((f) => [f.id, f]));
1679
1954
  const lang = ir.site.locales?.[0] ?? "en";
1680
1955
  const chrome = chromeComponents(ir.families);
@@ -1690,7 +1965,10 @@ function emitAstroProject(input) {
1690
1965
  if (ids.size) bodyMarkersByFamily.set(route.family, ids);
1691
1966
  }
1692
1967
  for (const family of ir.families) {
1693
- const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions, bodyMarkersByFamily.get(family.id) ?? []);
1968
+ const fam = familyFiles(family, lang, chrome.byFamily.get(family.id), definitions, bodyMarkersByFamily.get(family.id) ?? [], {
1969
+ seo,
1970
+ siteName: ir.site.title
1971
+ });
1694
1972
  add(fam.files);
1695
1973
  warnings.push(...fam.warnings);
1696
1974
  }
@@ -1708,7 +1986,7 @@ function emitAstroProject(input) {
1708
1986
  for (const post of allPosts) missingCss(post.css, `post ${post.slug}`);
1709
1987
  for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`);
1710
1988
  for (const route of ir.routes) {
1711
- const result = routeFiles(route, familiesById.get(route.family), input.content ?? {}, lang);
1989
+ const result = routeFiles(route, familiesById.get(route.family), input.content ?? {}, lang, seo);
1712
1990
  add(result.files);
1713
1991
  warnings.push(...result.warnings);
1714
1992
  }
@@ -1737,6 +2015,6 @@ async function writeEmit(result, dir) {
1737
2015
  await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
1738
2016
  }
1739
2017
  //#endregion
1740
- export { EMBED_TS, RUNTIME_IMPLEMENTED, balanceWarning, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, wrapLegacyCss, writeEmit };
2018
+ export { EMBED_TS, RUNTIME_IMPLEMENTED, SEO_COMPONENT, balanceWarning, bodySeoLeaks, checkBalance, componentMarkers, emitAstroProject, isRuntimeImplemented, pascalCase, patternToPagePath, stableJson, stripSeoTags, wrapLegacyCss, writeEmit };
1741
2019
 
1742
2020
  //# sourceMappingURL=index.mjs.map