@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/README.md +46 -0
- package/dist/index.d.mts +57 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +308 -24
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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: [
|
|
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
|
|
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
|
|
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
|