@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/README.md +39 -0
- package/dist/index.d.mts +57 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +299 -21
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|