Repository navigation
Expand file tree
/
Copy pathseo.mjs
More file actions
112 lines (103 loc) · 5.12 KB
/
Copy pathseo.mjs
File metadata and controls
112 lines (103 loc) · 5.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// Phase 2 SEO precompute. Per-page seoTitle / seoFullTitle / seoCanonical
// / seoIsHome, plus site-level seoSiteTitle / seoLogoUrl. The values are
// the exact text the head_seo.html template plugs into <title>, <meta>,
// canonical, and JSON-LD attributes.
//
// Mirrors `text | markdownify | strip_html | normalize_whitespace |
// escape_once` byte-for-byte: 834 of 836 page titles on this site are
// plain ASCII strings where the pipeline reduces to a straight
// escape_once(title); the 2 titles with markdown-active characters
// (`&, &=` and `\, \=` via Concat.md / LineContinuation analogues) get
// the same `<p>...</p>` wrap-and-strip as Jekyll produces.
//
// See builder/PLAN-2.md §5.7 + §6.3. Ports: _plugins/seo-precompute.rb.
import { absoluteUrl } from "./url.mjs";
import { isNonEmpty } from "./nav.mjs";
// Constants ported verbatim from Liquid::StandardFilters so the
// strip/escape steps use the same regex characters Liquid would.
const STRIP_HTML_BLOCKS = /<script.*?<\/script>|<!--.*?-->|<style.*?<\/style>/gms;
const STRIP_HTML_TAGS = /<\/?[^>]+>/g;
const HTML_ESCAPE_ONCE_REGEXP = /["><']|&(?!(?:[a-zA-Z]+|#\d+);)/g;
const HTML_ESCAPE = {
"&": "&",
">": ">",
"<": "<",
'"': """,
"'": "'",
};
// jekyll-seo-tag's HOMEPAGE_OR_ABOUT_REGEX expanded; toggles JSON-LD
// @type between WebSite and WebPage.
const HOMEPAGE_URLS = new Set(["/", "/index.html", "/index.htm", "/about/", "/about/index.html", "/about/index.htm"]);
// Site-level SEO constants: seoSiteTitle (rendered config.title) and
// seoLogoUrl (absolute, uri-escaped). Folded into markdownInit on main so
// dispatch can pack them into the shared SAB.
//
// The markdown-it instance is shared with Phase 3's body renderer
// (see render.mjs's createMarkdownIt). The plugin stack adds attrs /
// deflist / footnote / header-id / TOC / relative-links / block-HTML
// recursion / smart-dash + smart-quote helpers; none of those plugins
// fire on the bare-text titles this site uses (no `{:` attribute
// syntax, no `term\n: definition`, no `[^N]` footnote ref, no
// heading, no `{:toc}` marker, no <a> token, no html_block with
// markdown="1"). The visible effect of the shared instance is
// identical to the previous standalone `new MarkdownIt({ html:
// true, typographer: true })` for every title on the site.
export function computeSiteSeo(config, markdown) {
if (!markdown) {
throw new Error("computeSiteSeo requires a markdown-it instance (build via render.mjs's createMarkdownIt)");
}
const seoSiteTitle = renderTitle(config.title, markdown);
const logo = config.logo;
const seoLogoUrl = logo != null ? uriEscape(absoluteUrl(String(logo), config)) : null;
return { seoSiteTitle, seoLogoUrl };
}
// Per-page SEO fields: seoTitle / seoFullTitle / seoCanonical / seoIsHome.
// Mutates pages in place. Called on each render worker between renderPhase
// and templatePhase so the values never cross the postMessage boundary.
export function computeChunkSeo(pages, seoSiteTitle, config, markdown) {
for (const page of pages) {
const rawTitle = page.frontmatter.title;
const seoTitle = isNonEmpty(rawTitle) ? renderTitle(rawTitle, markdown) : seoSiteTitle;
page.seoTitle = seoTitle;
page.seoFullTitle = seoTitle === seoSiteTitle ? seoTitle : `${seoTitle} | ${seoSiteTitle}`;
const url = String(page.permalink);
// Canonical = deployment URL of the content. Strip both
// `/index.html` (folder-style page) and any trailing `.html`
// (page without explicit permalink) so the canonical claims the
// extensionless URL GitHub Pages serves -- the same form every
// permalinked page uses, so links to the same content all
// canonicalise consistently.
const canonicalInput = url.replace(/\/index\.html$/, "/").replace(/\.html$/, "");
page.seoCanonical = absoluteUrl(canonicalInput, config);
page.seoIsHome = HOMEPAGE_URLS.has(url);
}
}
// `text | markdownify | strip_html | normalize_whitespace | escape_once`,
// matching the Ruby pipeline byte-for-byte. markdown-it.render() adds a
// trailing \n; normalize_whitespace's /\s+/ + trim strips it back out.
export function renderTitle(text, markdown) {
if (text == null) return "";
const s = String(text);
if (s === "") return "";
const html = markdown.render(s);
const stripped = stripHtml(html);
const collapsed = stripped.replace(/\s+/g, " ").trim();
return collapsed.replace(HTML_ESCAPE_ONCE_REGEXP, (m) => HTML_ESCAPE[m]);
}
// Liquid's `strip_html` filter: drop <script>/<style> blocks and HTML
// comments outright, then strip the remaining tag delimiters. Exported
// for Phase 6's search-index content sanitiser (search.mjs).
export function stripHtml(s) {
return String(s ?? "")
.replace(STRIP_HTML_BLOCKS, "")
.replace(STRIP_HTML_TAGS, "");
}
// Mirrors `Jekyll::Filters#uri_escape` (=
// `Addressable::URI.normalize_component`). For the inputs this site
// produces (absolute https URLs whose components are already safe
// ASCII), encodeURI is equivalent: it leaves alphanumerics + the
// unreserved set + reserved-but-safe characters (/:?#@ etc.) intact.
function uriEscape(input) {
if (input == null) return null;
return encodeURI(String(input));
}