@contentrain/emitter-astro 0.9.1 → 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
@@ -87,7 +87,11 @@ function scaffoldFiles(ir, options) {
87
87
  files["tsconfig.json"] = `${JSON.stringify({
88
88
  extends: "astro/tsconfigs/base",
89
89
  include: [".astro/types.d.ts", "**/*"],
90
- exclude: ["dist"]
90
+ exclude: [
91
+ "dist",
92
+ "public",
93
+ "node_modules"
94
+ ]
91
95
  }, null, 2)}\n`;
92
96
  if (tailwind) files["src/styles/modern.css"] = modernCss(ir.tokens);
93
97
  files["src/lib/fill.ts"] = FILL_TS;
@@ -262,6 +266,49 @@ export function renderSections<T>(
262
266
  return out.join('')
263
267
  }
264
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
+
265
312
  /** Emitted stylesheets live under /styles/legacy/ — pages reference them by file name. */
266
313
  export const cssHref = (file: string): string => '/styles/legacy/' + (file.split('/').pop() ?? file)
267
314
 
@@ -338,8 +385,48 @@ export interface EntryRef {
338
385
  locale?: string
339
386
  }
340
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
+
341
419
  export interface EmittedPost extends MarkablePost {
342
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
343
430
  /** Route parameters beyond slug — the date parts of a dated permalink, a post id. */
344
431
  params?: Record<string, string>
345
432
  /** Stylesheets only this page loads. */
@@ -358,6 +445,9 @@ export interface EmittedQueryPage {
358
445
  item_template?: string
359
446
  sections?: ListSection[]
360
447
  title?: string
448
+ description?: string
449
+ image?: string
450
+ canonical?: string
361
451
  }
362
452
  `;
363
453
  //#endregion
@@ -476,6 +566,173 @@ function balanceWarning(html) {
476
566
  return parts.length ? parts.join("; ") : null;
477
567
  }
478
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
479
736
  //#region src/layouts.ts
480
737
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
481
738
  const MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\s]+)${escapeRe(CHROME_COMPONENT_CLOSE)}`, "g");
@@ -483,10 +740,11 @@ const MARKER_RE = new RegExp(`${escapeRe(CHROME_COMPONENT_OPEN)}([^@\\s]+)${esca
483
740
  function componentMarkers(html) {
484
741
  return [...new Set([...html.matchAll(MARKER_RE)].map((m) => m[1] ?? ""))];
485
742
  }
486
- function familyFiles(family, lang, components = [], definitions = /* @__PURE__ */ new Map(), bodyMarkers = []) {
743
+ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ */ new Map(), bodyMarkers = [], options = {}) {
487
744
  const name = pascalCase(family.id);
488
745
  const chunks = (family.chrome ?? []).filter((c) => c.position !== "header" && c.position !== "footer");
489
746
  const warnings = [];
747
+ const seoOn = options.seo !== false;
490
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`);
491
749
  const joined = (position) => chunks.filter((c) => c.position === position).map((c) => c.html).join("\n");
492
750
  const bodyChunks = chunks.filter((c) => c.position === "body");
@@ -498,6 +756,10 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
498
756
  body += CHROME_BODY_SLOT;
499
757
  }
500
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
+ }
501
763
  const unbalanced = balanceWarning(body);
502
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`);
503
765
  const placements = new Map((family.components ?? []).map((p) => [p.component, p]));
@@ -529,15 +791,23 @@ function familyFiles(family, lang, components = [], definitions = /* @__PURE__ *
529
791
  mount(id, def);
530
792
  }
531
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
+ }
532
800
  const files = {};
533
801
  files[`src/data/chrome/${family.id}.json`] = stableJson({
534
- head: joined("head"),
802
+ head,
535
803
  body,
536
804
  html_attrs: family.root_attrs?.html ?? {},
537
805
  body_attrs: family.root_attrs?.body ?? {}
538
806
  });
539
807
  const cssLinks = (family.css.files ?? []).map((f) => `<link rel="stylesheet" href="/styles/legacy/${f.split("/").pop()}" />`).join("\n");
540
- 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");
541
811
  const mountType = [...new Set(mounts.map((m) => `typeof ${m.name}`))].join(" | ");
542
812
  const mountTable = mounts.length ? `// Components the chrome mounts at <!--@@component:ID@@--> markers; the page's
543
813
  // entry address travels to each so a comments thread knows which entry it is.
@@ -555,7 +825,7 @@ const parts = splitComponents(html).map((part) => ({
555
825
  files[`src/layouts/${name}.astro`] = `---
556
826
  // Family: ${family.id}${family.name ? ` (${family.name})` : ""} — emitted by @contentrain/emitter-astro
557
827
  import chrome from '../data/chrome/${family.id}.json'
558
- 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'
559
829
  ${componentImports ? `${componentImports}\n` : ""}
560
830
  interface Props {
561
831
  title?: string
@@ -567,9 +837,11 @@ interface Props {
567
837
  /** Document language — a multilingual site's routes each pass their own. */
568
838
  lang?: string
569
839
  /** Content-store address of the page's entry — mounted components (comments) key on it. */
570
- 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` : ""}
571
843
  }
572
- 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
573
845
  const head = renderTemplate(chrome.head, marks)
574
846
  // Root attributes carry the theme's layout hooks; values may hold @@marks@@.
575
847
  // An explicit lang from the source wins over the project default.
@@ -590,7 +862,7 @@ ${cssLinks.split("\n").filter(Boolean).map((l) => ` ${l}`).join("\n")}
590
862
  <link rel="stylesheet" href={cssHref(file)} />
591
863
  ))}
592
864
  <Fragment set:html={head} />
593
- <title>{title}</title>
865
+ ${seoOn ? ` <Seo title={title} locale={lang}${siteNameProp} {...(seo ?? {})} />` : ` <title>{title}</title>`}
594
866
  </head>
595
867
  <body {...bodyAttrs}>
596
868
  ${renderRefs("header") ? `${renderRefs("header")}\n` : ""}${bodyRender}
@@ -604,7 +876,7 @@ ${renderRefs("footer") ? `${renderRefs("footer")}\n` : ""} </body>
604
876
  }
605
877
  //#endregion
606
878
  //#region src/pages.ts
607
- function routeFiles(route, family, content, siteLocale) {
879
+ function routeFiles(route, family, content, siteLocale, seo = true) {
608
880
  const warnings = [];
609
881
  const files = {};
610
882
  const pagePath = patternToPagePath(route.pattern);
@@ -627,7 +899,7 @@ function routeFiles(route, family, content, siteLocale) {
627
899
  const posts = collectionItems(content, collection);
628
900
  if (!posts.length) warnings.push(`route ${route.id}: collection "${collection}" has no items — page emitted, data file empty`);
629
901
  files[`src/data/${collection}.json`] = stableJson(posts);
630
- files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale);
902
+ files[`src/pages/${pagePath}`] = singlePage(route, layout, up, collection, siteLocale, hasParams, seo);
631
903
  return {
632
904
  files,
633
905
  warnings
@@ -638,13 +910,13 @@ function routeFiles(route, family, content, siteLocale) {
638
910
  if (!queryPages.length) warnings.push(`route ${route.id}: query "${route.query}" has no data — page emitted, data file empty`);
639
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)`);
640
912
  files[`src/data/queries/${route.query}.json`] = stableJson(queryPages);
641
- files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale);
913
+ files[`src/pages/${pagePath}`] = listPage(route, layout, up, hasParams, siteLocale, seo);
642
914
  return {
643
915
  files,
644
916
  warnings
645
917
  };
646
918
  }
647
- files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale);
919
+ files[`src/pages/${pagePath}`] = staticPage(route, layout, up, hasParams, warnings, siteLocale, seo);
648
920
  return {
649
921
  files,
650
922
  warnings
@@ -656,15 +928,15 @@ function collectionItems(content, collection) {
656
928
  if (named) return named;
657
929
  return collection === "posts" ? content.posts ?? [] : [];
658
930
  }
659
- function singlePage(route, layout, up, collection, siteLocale) {
931
+ function singlePage(route, layout, up, collection, siteLocale, hasParams, seo) {
660
932
  const locale = JSON.stringify(route.locale ?? siteLocale);
661
933
  return `---
662
934
  // Route: ${route.id} (${route.pattern}) — collection: ${collection} — emitted by @contentrain/emitter-astro
663
935
  import Layout from '${up}layouts/${layout}.astro'
664
936
  import data from '${up}data/${collection}.json'
665
- import { postMarks, type EmittedPost } from '${up}lib/fill'
937
+ import { postMarks${seo ? ", postSeo" : ""}, type EmittedPost } from '${up}lib/fill'
666
938
 
667
- export function getStaticPaths() {
939
+ ${hasParams ? `export function getStaticPaths() {
668
940
  // The JSON is data; its contract is EmittedPost. Inferring the type from the
669
941
  // file would type only the fields this site happens to carry, and "astro
670
942
  // check" (which the build runs first) would then reject post.params on a site
@@ -677,7 +949,9 @@ export function getStaticPaths() {
677
949
  return posts.map((post) => ({ params: { ...(post.params ?? {}), slug: post.slug }, props: { post } }))
678
950
  }
679
951
  type Props = { post: EmittedPost }
680
- const { post } = Astro.props as Props
952
+ const { post } = Astro.props as Props` : `const posts = data as EmittedPost[]
953
+ if (posts.length !== 1) throw new Error(${JSON.stringify(`Static content route requires exactly one entry: ${route.id}`)})
954
+ const post = posts[0]!`}
681
955
  ---
682
956
  <Layout
683
957
  title={post.title}
@@ -685,11 +959,12 @@ const { post } = Astro.props as Props
685
959
  body={post.body}
686
960
  css={post.css ?? []}
687
961
  lang={post.locale ?? ${locale}}
688
- entry={post.entry}
962
+ entry={post.entry}${seo ? `
963
+ seo={postSeo(post)}` : ""}
689
964
  />
690
965
  `;
691
966
  }
692
- function listPage(route, layout, up, hasParams, siteLocale) {
967
+ function listPage(route, layout, up, hasParams, siteLocale, seo) {
693
968
  const locale = JSON.stringify(route.locale ?? siteLocale);
694
969
  const routeTitle = route.title === void 0 ? `''` : JSON.stringify(route.title);
695
970
  const paths = hasParams ? `export function getStaticPaths() {
@@ -724,11 +999,12 @@ const title = page.title ?? ${routeTitle}
724
999
  marks={marks}
725
1000
  body={content}
726
1001
  css={page.css ?? []}
727
- lang={${locale}}
1002
+ lang={${locale}}${seo ? `
1003
+ seo={{ description: page.description, image: page.image, canonical: page.canonical, type: 'website' }}` : ""}
728
1004
  />
729
1005
  `;
730
1006
  }
731
- function staticPage(route, layout, up, hasParams, warnings, siteLocale) {
1007
+ function staticPage(route, layout, up, hasParams, warnings, siteLocale, seo) {
732
1008
  if (hasParams) warnings.push(`route ${route.id}: parameterized route without a query — emitted with empty paths`);
733
1009
  const paths = hasParams ? `export function getStaticPaths() {
734
1010
  return []
@@ -738,7 +1014,7 @@ function staticPage(route, layout, up, hasParams, warnings, siteLocale) {
738
1014
  // Route: ${route.id} (${route.pattern}) — emitted by @contentrain/emitter-astro
739
1015
  import Layout from '${up}layouts/${layout}.astro'
740
1016
  ${paths}---
741
- <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' }}` : ""} />
742
1018
  `;
743
1019
  }
744
1020
  //#endregion
@@ -1669,6 +1945,11 @@ function emitAstroProject(input) {
1669
1945
  }
1670
1946
  };
1671
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
+ }
1672
1953
  const familiesById = new Map(ir.families.map((f) => [f.id, f]));
1673
1954
  const lang = ir.site.locales?.[0] ?? "en";
1674
1955
  const chrome = chromeComponents(ir.families);
@@ -1684,7 +1965,10 @@ function emitAstroProject(input) {
1684
1965
  if (ids.size) bodyMarkersByFamily.set(route.family, ids);
1685
1966
  }
1686
1967
  for (const family of ir.families) {
1687
- 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
+ });
1688
1972
  add(fam.files);
1689
1973
  warnings.push(...fam.warnings);
1690
1974
  }
@@ -1702,7 +1986,7 @@ function emitAstroProject(input) {
1702
1986
  for (const post of allPosts) missingCss(post.css, `post ${post.slug}`);
1703
1987
  for (const [queryId, queryPages] of Object.entries(input.content?.queries ?? {})) for (const qp of queryPages) missingCss(qp.css, `query ${queryId}`);
1704
1988
  for (const route of ir.routes) {
1705
- const result = routeFiles(route, familiesById.get(route.family), input.content ?? {}, lang);
1989
+ const result = routeFiles(route, familiesById.get(route.family), input.content ?? {}, lang, seo);
1706
1990
  add(result.files);
1707
1991
  warnings.push(...result.warnings);
1708
1992
  }
@@ -1731,6 +2015,6 @@ async function writeEmit(result, dir) {
1731
2015
  await Promise.all(Object.entries(result.files).map(([path, content]) => writeFile(join(dir, path), content, "utf8")));
1732
2016
  }
1733
2017
  //#endregion
1734
- 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 };
1735
2019
 
1736
2020
  //# sourceMappingURL=index.mjs.map