freestruct 1.0.0 → 1.2.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/engine/seo.js CHANGED
@@ -7,45 +7,197 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import { hasSeoType } from './scan.js';
11
- import { pageUrlFor } from './urls.js';
10
+ import { hasSeoType, htmlIsNoindex, htmlToText } from './scan.js';
11
+ import { pageUrlFor, slugMatchesGlob, globSpecificity } from './urls.js';
12
12
 
13
13
  function minifyHtml(html) {
14
- // Conservative HTML minifier - removes unnecessary whitespace
15
- // Preserves content inside <pre>, <textarea>, <script>, <style> tags
16
- let inBlock = false;
17
- let result = '';
18
- let i = 0;
19
- while (i < html.length) {
20
- if (html.slice(i, i + 5) === '<pre>') inBlock = true;
21
- else if (html.slice(i, i + 10) === '<textarea') inBlock = true;
22
- else if (html.slice(i, i + 8) === '<script>') inBlock = true;
23
- else if (html.slice(i, i + 7) === '<style>') inBlock = true;
24
- else if (html.slice(i, i + 6) === '</pre>') inBlock = false;
25
- else if (html.slice(i, i + 11) === '</textarea>') inBlock = false;
26
- else if (html.slice(i, i + 9) === '</script>') inBlock = false;
27
- else if (html.slice(i, i + 8) === '</style>') inBlock = false;
28
-
29
- if (inBlock) result += html[i];
30
- else if (html[i] === '\n' || html[i] === '\r' || html[i] === '\t') { /* skip */ }
31
- else result += html[i];
32
- i++;
33
- }
34
- return result
14
+ // Conservative HTML minifier - removes unnecessary whitespace OUTSIDE
15
+ // verbatim blocks. The <pre>/<script>/<style>/<textarea> CONTENT is
16
+ // preserved byte-for-byte (README's contract): whitespace there is
17
+ // significant (code fences, inline JS with `//` comments, CSS), so the
18
+ // collapse must never reach inside a block. Blocks are matched with their
19
+ // attribute forms (`<pre class="highlight">`, `<script type="...">`) - the
20
+ // old open-tag literals only caught a bare `<pre>`.
21
+ const blockRe = /<(pre|script|style|textarea)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
22
+ const shrink = (s) => s
23
+ .replace(/\r/g, '')
24
+ // A line break inside TEXT is a word separator, never a joiner: the old
25
+ // `[ \t]*\n[ \t]* -> ''` fused 'Hello\nworld' into 'Helloworld'. Collapse
26
+ // the break to a single space instead; the between-tag rule below then
27
+ // removes the ones that sit between tags (where whitespace is meaningless).
28
+ .replace(/[ \t]*\n[ \t]*/g, ' ')
35
29
  .replace(/>\s+</g, '><')
36
- .replace(/\s+/g, ' ')
30
+ .replace(/[ \t]{2,}/g, ' ')
37
31
  .trim();
32
+ const parts = [];
33
+ let last = 0;
34
+ let m;
35
+ while ((m = blockRe.exec(html)) !== null) {
36
+ if (m.index > last) parts.push(shrink(html.slice(last, m.index)));
37
+ parts.push(m[0]);
38
+ last = blockRe.lastIndex;
39
+ }
40
+ if (last < html.length) parts.push(shrink(html.slice(last)));
41
+ return parts.join('').trim();
42
+ }
43
+
44
+ // ---------- SEO-gap pass helpers (feed link / SearchAction / breadcrumb / article / hreflang) ----------
45
+
46
+ // The root-home stem is the root document; glob '**' retries use it too.
47
+ const rootHomeSlug = '/';
48
+
49
+ // Literal prefix of a locale pattern: the part every matched slug must start
50
+ // with (glob patterns drop the glob tail; the root document retries to '').
51
+ function stemForPattern(pattern) {
52
+ if (pattern === rootHomeSlug) return '';
53
+ if (typeof pattern === 'string') {
54
+ const star = pattern.indexOf('*');
55
+ if (star !== -1) return pattern.slice(0, star);
56
+ return pattern;
57
+ }
58
+ return rootHomeSlug;
59
+ }
60
+
61
+ // Root-home retries: a rest of '/' or '' is the home page, not '//'.
62
+ function slugForHomePage(slug) { return slug === rootHomeSlug ? rootHomeSlug : slug; }
63
+
64
+ /**
65
+ * Per-page article LD+JSON type (SEO-gap pass): config.article.scoped is a
66
+ * slug-glob map of type strings; the most specific pattern matching the page
67
+ * wins (exact > glob), invalid types are skipped (config.js warned loudly
68
+ * at load).
69
+ */
70
+ const ARTICLE_TYPES = Object.freeze(['Article', 'TechArticle', 'NewsArticle']);
71
+ function articleTypeForPage(page, config) {
72
+ const scoped = config.article?.scoped;
73
+ if (!scoped || typeof scoped !== 'object' || Array.isArray(scoped)) return null;
74
+ let bestScore = -1;
75
+ let best = null;
76
+ for (const [pattern, v] of Object.entries(scoped)) {
77
+ if (!ARTICLE_TYPES.includes(String(v))) continue;
78
+ if (!slugMatchesGlob(pattern, page.slug)) continue;
79
+ const score = globSpecificity(pattern);
80
+ if (score > bestScore) { bestScore = score; best = String(v); }
81
+ }
82
+ return best;
83
+ }
84
+
85
+ /**
86
+ * Auto BreadcrumbList (SEO-gap pass): config.breadcrumb true builds the
87
+ * breadcrumb from the page's own position in the URL tree - the site root,
88
+ * then every URL segment in order, with the page's own title for the tail.
89
+ * The root page (no segments) gets nothing - a one-element breadcrumb is
90
+ * not a breadcrumb.
91
+ */
92
+ function breadcrumbJsonLd(page, config) {
93
+ if (config.breadcrumb !== true) return null;
94
+ const segs = String(page.slug).split('/').filter(Boolean);
95
+ if (segs.length === 0) return null;
96
+ const items = [{ position: 1, name: config.site.name, url: pageUrlFor(config, rootHomeSlug) }];
97
+ let acc = '';
98
+ segs.forEach((seg, i) => {
99
+ acc += '/' + seg;
100
+ // The LEAF item is the current page: use its own canonical slug, so a
101
+ // directory page's breadcrumb URL matches its canonical ('/guide/', not
102
+ // the segment-join '/guide' the intermediate levels use). Intermediate
103
+ // segments have no page of their own, so the join stays their URL.
104
+ const isLeaf = i === segs.length - 1;
105
+ items.push({
106
+ position: i + 2,
107
+ name: (isLeaf && page.title) ? page.title : seg.replace(/[-_]/g, ' '),
108
+ url: pageUrlFor(config, isLeaf ? page.slug : acc),
109
+ });
110
+ });
111
+ return {
112
+ '@context': 'https://schema.org',
113
+ '@type': 'BreadcrumbList',
114
+ itemListElement: items.map((it) => ({
115
+ '@type': 'ListItem',
116
+ position: it.position,
117
+ name: it.name,
118
+ item: it.url,
119
+ })),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Does this config's jsonLd take the SCOPED map form (every key a page-slug
125
+ * glob)? The injection path (below) and the generated-extras skip must agree
126
+ * on this - they diverged once: the injection accepted the bare `**` key
127
+ * (documented as "anywhere at all") while the skip required every key to
128
+ * start with '/', so a `**`-keyed map injected its block AND the generated
129
+ * article, shipping two article blocks on the page.
130
+ */
131
+ function isScopedJsonLdMap(jc) {
132
+ return !Array.isArray(jc) && Boolean(jc) && typeof jc === 'object'
133
+ && Object.keys(jc).length > 0
134
+ && Object.keys(jc).every((k) => /(^\/|^\*$|^\*\*$|\*)/.test(k));
38
135
  }
39
136
 
137
+ /**
138
+ * Does this page RECEIVE a scoped jsonLd block? The scoped map form keys
139
+ * slug globs to block lists (config.js reads legacy forms - a schema object
140
+ * or an array of them - as the global set, with non-glob keys). Pages that
141
+ * receive a scoped block skip the GENERATED article block (the documented
142
+ * skip: a consumer-authored block outranks the derived one), while the
143
+ * breadcrumb trail coexists with any config jsonLd form.
144
+ */
145
+ function pageReceivesScopedJsonLd(page, config) {
146
+ const jsonLd = config.jsonLd;
147
+ if (!isScopedJsonLdMap(jsonLd)) return false;
148
+ return Object.keys(jsonLd).some((k) => slugMatchesGlob(k, page.slug));
149
+ }
150
+
151
+ /**
152
+ * hreflang alternates for a page: the locales map keys slug globs to
153
+ * language codes; the most specific matching pattern decides the page's own
154
+ * language, the others give the replica slugs (the page's slug with the
155
+ * matched stem swapped for each sibling stem). This is a stem-SWAP guess,
156
+ * not a file-existence contract: a page that only exists in one language
157
+ * still gets the alternates entry, and its 404 is the SSG's problem.
158
+ */
159
+ function hreflangAlternatesFor(page, config) {
160
+ const locales = config.locales;
161
+ if (!locales || typeof locales !== 'object' || Array.isArray(locales)) return null;
162
+ const entries = Object.entries(locales).filter(([, v]) => typeof v === 'string' && v !== '');
163
+ if (entries.length === 0) return null;
164
+ let bestScore = -1;
165
+ let ownPattern = null;
166
+ let ownLang = null;
167
+ for (const [pattern, lang] of entries) {
168
+ if (!slugMatchesGlob(pattern, page.slug)) continue;
169
+ const score = globSpecificity(pattern);
170
+ if (score > bestScore) { bestScore = score; ownPattern = pattern; ownLang = lang; }
171
+ }
172
+ if (!ownPattern) return null;
173
+ const ownStem = stemForPattern(ownPattern);
174
+ const stripLen = ownStem === rootHomeSlug ? 0 : ownStem.length;
175
+ let rest = String(page.slug).slice(stripLen);
176
+ if (!rest.startsWith('/')) rest = '/' + rest;
177
+ const out = [];
178
+ for (const [pattern, lang] of entries) {
179
+ if (lang === ownLang) continue;
180
+ const stem = stemForPattern(pattern);
181
+ const stemClean = stem === rootHomeSlug ? '' : stem.replace(/\/+$/, '');
182
+ out.push([lang, pageUrlFor(config, stemClean === '' ? (rest === '/' ? rootHomeSlug : rest) : slugForHomePage(stemClean + rest))]);
183
+ }
184
+ // x-default follows the root document's locale when one is configured.
185
+ const rootEntry = entries.find(([pattern]) => stemForPattern(pattern) === rootHomeSlug);
186
+ if (rootEntry && rootEntry[1] !== ownLang) {
187
+ out.push(['x-default', pageUrlFor(config, rest === '/' ? rootHomeSlug : rest)]);
188
+ }
189
+ return out.length ? out : null;
190
+ }
40
191
  // ---------- injection value escaping ----------
41
192
 
193
+
42
194
  // HTML-attribute escaping for config/page values that land inside
43
195
  // content="..." or href="...": a raw " in a site description silently
44
196
  // truncates every generated tag (ported from prestruct's escapeHtmlAttr).
45
197
  // '&' is deliberately NOT escaped: the battery pins the raw ampersand in
46
198
  // canonical attributes as valid HTML, and escaping it would double-encode
47
199
  // legit URLs that already contain &amp; from the SSG.
48
- function escAttr(value) {
200
+ export function escAttr(value) {
49
201
  return String(value == null ? '' : value)
50
202
  .replace(/</g, '&lt;')
51
203
  .replace(/>/g, '&gt;')
@@ -106,12 +258,50 @@ function readPageOverrides(html, page) {
106
258
  * pages that ship their own OG/Twitter/JSON-LD/canonical/description keep them.
107
259
  * Page-level freestruct comments refine the injected tags (see PERPAGE_KEYS).
108
260
  */
109
- export function injectPage(page, { config, template, buildHash, snippetDir }) {
261
+ // Marker-wrapped custom snippets: the wrapper makes the injected region
262
+ // identifiable so a RERUN can drop the previous copy before re-adding it
263
+ // (without this, each pass stacked another snippet and, for body/footer
264
+ // snippets, changed the body text - churning the content-derived build hash
265
+ // every run). The wrapper is freestruct's own; snippet content comments are
266
+ // already stripped by readSnippet.
267
+ const SNIPPET_OPEN = '<!-- freestruct:snippet -->';
268
+ const SNIPPET_CLOSE = '<!-- /freestruct:snippet -->';
269
+
270
+ /**
271
+ * Remove every region a PREVIOUS freestruct run wrote into a page, so a
272
+ * second pass re-derives it instead of stacking duplicates. Two tool-owned
273
+ * regions: the generated head tail (the `<!-- freestruct -->` marker through
274
+ * `</head>`: seo block, version tag, header snippet) and the marker-wrapped
275
+ * body-start/footer snippets. Both are entirely freestruct's, so removing
276
+ * them is safe; consumer/SSG content never lives inside either.
277
+ */
278
+ function stripInjectedRegions(html) {
279
+ return html
280
+ .replace(/<!-- freestruct -->[\s\S]*?(?=<\/head>)/i, '')
281
+ // The snippets are inserted with NO surrounding newlines (the snippet
282
+ // files' own trailing newline rides INSIDE the markers), so this removal
283
+ // restores the page to its pre-injection bytes and a rerun is byte-identical.
284
+ .replace(/<!-- freestruct:snippet -->[\s\S]*?<!-- \/freestruct:snippet -->/gi, '');
285
+ }
286
+
287
+ export function injectPage(page, { config, template, buildHash, snippetDir, injectWarnings, generatedImages }) {
110
288
  let html = page.html;
111
289
  const siteName = config.site.name;
112
290
  const siteUrl = config.site.url;
291
+ // Rerun hygiene first: drop the previous pass's output so this pass derives
292
+ // the same result (idempotent reruns) rather than duplicating snippets.
293
+ html = stripInjectedRegions(html);
113
294
  const overrides = readPageOverrides(html, page);
114
295
 
296
+ // Headless-page diagnostics (Pass 26.13): every injected surface anchors
297
+ // on the head close tag, so a .html file without one silently takes NO
298
+ // head injection - the audit names the gaps afterwards, but the RUN says
299
+ // so here too (headless fragments are the octet-stream post class: never
300
+ // die silently).
301
+ if (!/<\/head>/i.test(html)) {
302
+ pageWarn(page, 'no </head> found - the whole head-injection surface skips this page');
303
+ }
304
+
115
305
  let pageTitle = (page.title || siteName) + ' | ' + siteName;
116
306
  let pageDescription = (page.description !== undefined && page.description !== null)
117
307
  ? page.description
@@ -128,31 +318,209 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
128
318
  const canonicalUrl = pageUrlFor(config, page.slug)
129
319
  + (config.cacheBusting?.hashInCanonicalUrl === true ? '?v=' + buildHash : '');
130
320
 
131
- // og image: the per-page comment wins over config; relative values are
132
- // joined onto the site url, absolute HTTP(S) values pass through as-is.
133
- // No image anywhere: no og:image/twitter:image ship at all (prestruct
134
- // parity - a bare image tag pointing at the site root is a broken card).
135
- const rawOgImage = (overrides?.ogImage !== undefined && overrides.ogImage !== '')
321
+ // og image (issue #20): config accepts the back-compat string form or the
322
+ // structured { src, width, height, alt } object form (shared prestruct
323
+ // shape so configs move between engines). Per-page overrides win over the
324
+ // config either way; relative values join onto the site url, absolute
325
+ // HTTP(S) values pass through as-is. No image anywhere: no og:image/
326
+ // twitter:image ship at all (a bare image tag pointing at the site root is
327
+ // a broken card).
328
+ const ogImageCfg = config.og?.image;
329
+ const ogImageObj = (ogImageCfg !== undefined && ogImageCfg !== null
330
+ && typeof ogImageCfg === 'object' && !Array.isArray(ogImageCfg))
331
+ ? ogImageCfg : null;
332
+ const hasPerpageOgImage = overrides?.ogImage !== undefined && overrides.ogImage !== '';
333
+ // Resolution order (issue #20 + #22): per-page override > per-page
334
+ // GENERATED image (issue #22 deliverables, keyed by slug) > the config
335
+ // (object or string form). Pages that ship their own og:image keep it
336
+ // before any of this matters - missing-only.
337
+ const generatedForPage = (generatedImages && generatedImages[page.slug]) || null;
338
+ // og.scoped (issue #25 follow-on): image variants keyed by the same slug
339
+ // globs as jsonLd; the MOST SPECIFIC matching pattern wins (exact > glob).
340
+ // Per-page overrides and per-page generated images (#22) still rank ahead;
341
+ // the global og.image is the deepest fallback.
342
+ const ogScopedCfg = config.og?.scoped;
343
+ let scopedForPage = null;
344
+ if (ogScopedCfg && typeof ogScopedCfg === 'object' && !Array.isArray(ogScopedCfg)) {
345
+ let bestScore = -1;
346
+ for (const [pattern, variant] of Object.entries(ogScopedCfg)) {
347
+ if (!slugMatchesGlob(pattern, page.slug)) continue;
348
+ const score = globSpecificity(pattern);
349
+ if (score > bestScore) { bestScore = score; scopedForPage = variant; }
350
+ }
351
+ }
352
+ // Defensive shape gate (config.js already warns loudly at load): a variant
353
+ // that is neither a string nor a plain object (god forbid a list - one page
354
+ // matches ONE image) resolves to NO image, never to synthesized garbage.
355
+ if (scopedForPage !== null && scopedForPage !== undefined
356
+ && typeof scopedForPage !== 'string'
357
+ && (typeof scopedForPage !== 'object' || Array.isArray(scopedForPage))) {
358
+ scopedForPage = null;
359
+ }
360
+ const scopedIsObject = scopedForPage !== null && typeof scopedForPage === 'object' && !Array.isArray(scopedForPage);
361
+ const allowSvgAccept = scopedForPage === null
362
+ ? (ogImageObj ? ogImageObj.allowSvg === true : false)
363
+ : (scopedIsObject ? scopedForPage.allowSvg === true : false);
364
+ const companionsSource = hasPerpageOgImage ? null
365
+ : (generatedForPage || (scopedIsObject ? scopedForPage : null) || (!scopedForPage ? ogImageObj : null));
366
+ const rawOgImage = hasPerpageOgImage
136
367
  ? String(overrides.ogImage)
137
- : config.og?.image || '';
368
+ : (generatedForPage ? generatedForPage.src
369
+ : (scopedForPage !== null
370
+ ? (scopedIsObject ? String(scopedForPage.src || '') : String(scopedForPage))
371
+ : (ogImageObj ? String(ogImageObj.src || '') : (ogImageCfg || ''))));
138
372
  const ogImageFull = rawOgImage && !/^(https?:)?\/\//i.test(rawOgImage)
139
- ? siteUrl + rawOgImage
373
+ ? pageUrlFor(config, rawOgImage)
140
374
  : rawOgImage;
141
375
 
376
+ // issue #20, the SVG trap: an SVG og:image cannot be rendered by most
377
+ // social scrapers and the share preview blanks. Loud warning always from
378
+ // here (config value or per-page override); under --strict the orchestrator
379
+ // graduates the collected offenses to build failures (same ladder as the
380
+ // report classes), and allowSvg: true is the explicit way out.
381
+ if (rawOgImage && /\.svg$/i.test(rawOgImage.split(/[?#]/)[0])
382
+ && hasPerpageOgImage !== true && !allowSvgAccept) {
383
+ // Loud in every run; under --strict the orchestrator also graduates the
384
+ // collected offenses (injectWarnings) to build failures.
385
+ pageWarn(page, 'og image "' + rawOgImage + '" is an SVG: most social scrapers cannot render it and the share preview blanks (ship a raster image, or set allowSvg: true in the og.image object to accept it)');
386
+ injectWarnings.push('og image "' + rawOgImage + '" is an SVG: most social scrapers cannot render it and the share preview blanks (ship a raster image, or set allowSvg: true in the og.image object to accept it)');
387
+ }
388
+
142
389
  // Add cache-busting query param to assets (css, js, images, fonts, ...)
143
390
  if (config.cacheBusting?.assetQueryParam !== false) {
144
- const assetExts = '.css.js.png.jpg.jpeg.gif.svg.webp.ico.woff.woff2.ttf.otf.wasm.json';
145
- html = html.replace(/(href|src)=\"([^\"]+)\"/gi,
146
- (match, attr, url) => {
147
- const cleanUrl = url.replace(/\?v=[^"]*/, '');
148
- const ext = cleanUrl.substring(cleanUrl.lastIndexOf('.'));
149
- if (assetExts.includes(ext)) {
150
- return attr + '=\"' + cleanUrl + '?v=' + buildHash + '\"';
151
- }
152
- return match;
391
+ // A real extension SET, matched case-insensitively. The old
392
+ // `assetExts.includes(ext)` was a substring test, so '/x.cs' ('.cs' is a
393
+ // substring of '.css'), '/x.tt' ('.ttf'), '/x.j' ('.js') all got busted
394
+ // while a genuine '/img.PNG' was missed.
395
+ const assetExts = new Set(['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf', '.otf', '.wasm', '.json']);
396
+ // Both quote styles (Pass 26.21): the old double-quote-only pattern left
397
+ // a single-quoted href/src (valid HTML, common in hand-authored markup)
398
+ // un-busted, so a stale asset survived a content change on those pages.
399
+ //
400
+ // Protected blocks (Pass 26.22): a docs page that SHOWS a link in a code
401
+ // example (`<pre><code>&lt;link href="/style.css"&gt;</code></pre>`) is
402
+ // markup-in-prose, not a live asset reference - rewriting it turned the
403
+ // example into fiction. An inline <script> body can likewise carry
404
+ // href=/src= string literals. Mask those regions, bust the live
405
+ // attributes, then restore; a <script src> OPENING tag stays live (only
406
+ // its body is masked). Same payload-stripping doctrine linkcheck owns.
407
+ const masks = [];
408
+ const mask = (s) => { masks.push(s); return '\u0000FS' + (masks.length - 1) + '\u0000'; };
409
+ html = html
410
+ .replace(/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi, (m) => {
411
+ const openEnd = m.indexOf('>') + 1;
412
+ const closeLen = m.match(/<\/script\s*>$/i)[0].length;
413
+ return m.slice(0, openEnd) + mask(m.slice(openEnd, m.length - closeLen)) + m.slice(m.length - closeLen);
414
+ })
415
+ .replace(/<(pre|code|textarea|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, (m) => mask(m));
416
+ // Same-origin policy (owner fix): only the site's own assets are ours to
417
+ // invalidate. An off-site URL (another CDN's host) gains nothing from a
418
+ // ?v= param, and a SIGNED url (an S3 presigned link carrying
419
+ // X-Amz-Signature) is actively CORRUPTED by an appended param - the
420
+ // signature no longer matches and the asset 403s. Absolute (scheme://host)
421
+ // and protocol-relative (//host) references are busted only when the host
422
+ // is the configured site; every other scheme (data:, blob:, mailto:, tel:)
423
+ // is left alone.
424
+ const siteOrigin = (() => {
425
+ try { return new URL(config.site?.url || '').origin; } catch { return null; }
426
+ })();
427
+ const siteHost = (() => {
428
+ try { return new URL(config.site?.url || '').host; } catch { return null; }
429
+ })();
430
+ // { origin, pathname } for a reference we may bust (origin '' = same-origin
431
+ // relative), or null to skip outright (fragment-only, or a non-http scheme).
432
+ const parseAssetRef = (raw) => {
433
+ if (!raw || raw[0] === '#') return null;
434
+ if (raw.startsWith('//')) {
435
+ const host = raw.slice(2).split(/[/?#]/)[0];
436
+ return { origin: '//' + host, pathname: raw.slice(2 + host.length) };
437
+ }
438
+ const m = /^([a-z][a-z0-9+.-]*):\/\//i.exec(raw);
439
+ if (m) {
440
+ const rest = raw.slice(m[0].length);
441
+ const host = rest.split(/[/?#]/)[0];
442
+ return { origin: m[1].toLowerCase() + '://' + host, pathname: rest.slice(host.length) };
443
+ }
444
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return null; // data:, blob:, mailto:, tel:, ...
445
+ return { origin: '', pathname: raw }; // same-origin relative
446
+ };
447
+ const isSameOrigin = (origin) => origin === ''
448
+ || (siteOrigin !== null && origin === siteOrigin)
449
+ || (siteHost !== null && origin === '//' + siteHost);
450
+ // The extension lives on the PATH: split the fragment and query off first,
451
+ // so an asset the SSG emitted with an existing query ('style.css?foo=bar')
452
+ // or fragment ('style.css#iefix') is still recognized. The version param
453
+ // joins the existing query with '&' and a previous freestruct 'v=' is
454
+ // dropped (rerun hygiene). Returns the URL unchanged when it is not a
455
+ // bustable same-origin asset.
456
+ const bustAssetRef = (raw) => {
457
+ const ref = parseAssetRef(raw);
458
+ if (!ref || !isSameOrigin(ref.origin)) return raw;
459
+ const { origin, pathname } = ref;
460
+ const hashAt = pathname.indexOf('#');
461
+ const fragment = hashAt === -1 ? '' : pathname.slice(hashAt);
462
+ const noFrag = hashAt === -1 ? pathname : pathname.slice(0, hashAt);
463
+ const qAt = noFrag.indexOf('?');
464
+ const pathPart = qAt === -1 ? noFrag : noFrag.slice(0, qAt);
465
+ const query = qAt === -1 ? '' : noFrag.slice(qAt + 1);
466
+ const dot = pathPart.lastIndexOf('.');
467
+ const ext = dot === -1 ? '' : pathPart.slice(dot).toLowerCase();
468
+ if (!assetExts.has(ext)) return raw;
469
+ const keptParams = query.split('&').filter(p => p && !/^v=/.test(p));
470
+ keptParams.push('v=' + buildHash);
471
+ return origin + pathPart + '?' + keptParams.join('&') + fragment;
472
+ };
473
+ html = html.replace(/(href|src)=(["'])([^"']+)\2/gi,
474
+ (match, attr, quote, url) => attr + '=' + quote + bustAssetRef(url) + quote);
475
+ // srcset (responsive images): the SAME url list rides <img srcset>,
476
+ // <picture><source srcset> and <link imagesrcset>, and it was never
477
+ // busted - so on any responsive image the primary (largest) candidate
478
+ // stayed stale at the edge while src moved on. A candidate is
479
+ // "url [descriptor]"; a naive comma split corrupts a data: URI (its
480
+ // payload carries commas), so this follows the HTML spec's splitting
481
+ // loop: skip whitespace/commas, take the non-whitespace run as the url
482
+ // (stripping trailing separators), then the run up to the next comma as
483
+ // the descriptor. Rejoined with the canonical ", " separator.
484
+ const isSrcsetWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
485
+ const parseSrcset = (value) => {
486
+ const out = [];
487
+ let i = 0;
488
+ const n = value.length;
489
+ while (i < n) {
490
+ while (i < n && (isSrcsetWs(value[i]) || value[i] === ',')) i++;
491
+ if (i >= n) break;
492
+ let start = i;
493
+ while (i < n && !isSrcsetWs(value[i])) i++;
494
+ let url = value.slice(start, i);
495
+ while (url.endsWith(',')) url = url.slice(0, -1);
496
+ if (url === '') continue;
497
+ while (i < n && isSrcsetWs(value[i])) i++;
498
+ start = i;
499
+ while (i < n && value[i] !== ',') i++;
500
+ out.push({ url, descriptor: value.slice(start, i).trim() });
501
+ }
502
+ return out;
503
+ };
504
+ html = html.replace(/srcset=(["'])([^"']*)\1/gi,
505
+ (match, quote, value) => {
506
+ const busted = parseSrcset(value)
507
+ .map(({ url, descriptor }) => bustAssetRef(url) + (descriptor ? ' ' + descriptor : ''))
508
+ .join(', ');
509
+ return 'srcset=' + quote + busted + quote;
153
510
  });
511
+ html = html.replace(/\u0000FS(\d+)\u0000/g, (m, i) => masks[Number(i)]);
154
512
  }
155
513
 
514
+ // SearchAction (config.search.url, SEO-gap pass): the seam expands to ''
515
+ // (no potentialAction at all - the hardcoded ?s= default is gone) or to
516
+ // the configured search target. The {search_term_string} placeholder is
517
+ // required by the schema.org contract; config.js already warns loudly at
518
+ // load when it is missing (and the block then never ships).
519
+ const searchActionJson = (config.search?.url && config.search.url.includes('{search_term_string}'))
520
+ ? ',\n "potentialAction": { "@type": "SearchAction", "target": "'
521
+ + escJson(config.search.url) + '", "query-input": "required name=search_term_string" }'
522
+ : '';
523
+
156
524
  const replacements = {
157
525
  '{{pageTitle}}': escAttr(pageTitle),
158
526
  '{{pageDescription}}': escAttr(pageDescription),
@@ -178,29 +546,105 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
178
546
  ? ',\n "founder": { "@type": "Person", "name": "' + escJson(config.author.name) + '"'
179
547
  + (config.author.url ? ', "url": "' + escJson(config.author.url) + '"' : '') + ' }'
180
548
  : '',
549
+ // searchActionJson seam (SEO-gap pass): '' or the configured block
550
+ '{{searchActionJson}}': searchActionJson,
181
551
  };
182
552
 
183
- // Custom JSON-LD hook (config.jsonLd): a single object or an array of
184
- // objects appended after the built-in blocks. Ships through the same
185
- // missing-only gate as the built-ins, and '<' is neutralized so a value
186
- // cannot close the script tag early (same posture as the XML escape-or-fail).
553
+ // Custom JSON-LD hook (config.jsonLd, issue #25): three accepted shapes -
554
+ // single object, array of objects (legacy, every page), or the scoped
555
+ // map form keyed by page-slug globs ('/' exact, '/blog/*' one segment,
556
+ // '/guides/**' the tree). The scoped form lands per page: global first,
557
+ // then every matching scoped block in specificity order (exact >
558
+ // literal-heavy > '**'). missing-only is untouched: pages shipping
559
+ // their own JSON-LD get nothing here, scoped or not. All blocks ship
560
+ // through the same serialization ('<' neutralized) as the legacy form.
187
561
  let customJsonLd = '';
188
562
  if (config.jsonLd !== undefined) {
189
- const blocks = Array.isArray(config.jsonLd) ? config.jsonLd : [config.jsonLd];
190
- for (let i = 0; i < blocks.length; i++) {
191
- const block = blocks[i];
192
- if (!block || typeof block !== 'object' || Array.isArray(block)) {
193
- console.log('[freestruct] config warning: jsonLd[' + i + '] is not an object; block skipped');
194
- continue;
563
+ const jc = config.jsonLd;
564
+ const isScopedMap = isScopedJsonLdMap(jc);
565
+ if (isScopedMap) {
566
+ const keys = Object.keys(jc).sort((a, b) => globSpecificity(b) - globSpecificity(a));
567
+ for (const key of keys) {
568
+ if (!slugMatchesGlob(key, page.slug)) continue;
569
+ const blocks = Array.isArray(jc[key]) ? jc[key] : [jc[key]];
570
+ if (!blocks.length || blocks.some((b) => !b || typeof b !== 'object' || Array.isArray(b))) {
571
+ console.log('[freestruct] config warning: jsonLd "' + key + '" must map to an LD+JSON object or a list of them; block skipped');
572
+ continue;
573
+ }
574
+ for (const block of blocks) {
575
+ const serialized = JSON.stringify(block).replace(/</g, '\\u003c');
576
+ customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
577
+ }
195
578
  }
579
+ } else {
580
+ const blocks = Array.isArray(jc) ? jc : [jc];
581
+ for (let i = 0; i < blocks.length; i++) {
582
+ const block = blocks[i];
583
+ if (!block || typeof block !== 'object' || Array.isArray(block)) {
584
+ console.log('[freestruct] config warning: jsonLd[' + i + '] is not an object; block skipped');
585
+ continue;
586
+ }
587
+ const serialized = JSON.stringify(block).replace(/</g, '\\u003c');
588
+ customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
589
+ }
590
+ }
591
+ }
592
+
593
+ // Per-page Article/TechArticle LD+JSON (SEO-gap pass, config.article
594
+ // scoped) + auto BreadcrumbList (config.breadcrumb, SEO-gap pass): both
595
+ // auto-emit ONLY for pages that ship no JSON-LD of their own AND get no
596
+ // configured jsonLd block at all (missing-only, and a scoped jsonLd key
597
+ // counts as the consumer's explicit per-page choice - no double block).
598
+ // 404 pages keep the FULL pipeline's per-page tags (issue #11: the 404 is
599
+ // finished like every page), but the generated extras are probe-junk on a
600
+ // noindex page: a breadcrumb trail for a 404, typed article schema, hreflang
601
+ // alternates, the feed alternate and verification metas all say nothing to
602
+ // a crawler that has been told to skip the page. The extras therefore skip
603
+ // 404s AND robots-noindex pages (the same exclusion class sitemap/feed/
604
+ // search/llms already honor); the core injected set stays everywhere.
605
+ const noindexExtrasGate = page.is404 === true || htmlIsNoindex(html);
606
+ // Missing-only class: the page's OWN pre-existing ld+json suppresses the
607
+ // generated extras. Config-level jsonLd is OUR block - it coexists with
608
+ // the generated ones (Pass 26.9: the old gate refused the extras whenever
609
+ // ANY config jsonLd existed, silently killing breadcrumb/article on sites
610
+ // that also ship a global custom block - the docs site itself was bit).
611
+ const pageOwnsJsonLd = hasSeoType(html, 'jsonld');
612
+ if (!noindexExtrasGate && !pageOwnsJsonLd) {
613
+ const articleType = !pageReceivesScopedJsonLd(page, config)
614
+ ? articleTypeForPage(page, config)
615
+ : null;
616
+ if (articleType) {
617
+ const block = {
618
+ '@context': 'https://schema.org',
619
+ '@type': articleType,
620
+ headline: pageTitle,
621
+ url: canonicalUrl,
622
+ };
623
+ const artAuthor = overrides?.author || config.author?.name;
624
+ if (artAuthor) block.author = { '@type': 'Person', name: String(artAuthor) };
625
+ if (overrides?.publishedTime) block.datePublished = String(overrides.publishedTime);
626
+ if (pageDescription) block.description = pageDescription;
627
+ if (ogImageFull) block.image = ogImageFull;
196
628
  const serialized = JSON.stringify(block).replace(/</g, '\\u003c');
197
629
  customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
198
630
  }
631
+ const breadcrumbLd = breadcrumbJsonLd(page, config);
632
+ if (breadcrumbLd) {
633
+ const serialized = JSON.stringify(breadcrumbLd).replace(/</g, '\\u003c');
634
+ customJsonLd += '\n<script type="application/ld+json">\n' + serialized + '\n</script>\n';
635
+ }
199
636
  }
200
637
 
201
638
  let seo = template;
202
639
  for (const [k, v] of Object.entries(replacements)) seo = seo.split(k).join(v);
203
640
  if (customJsonLd) seo += customJsonLd;
641
+ // A page (and site) with no description source anywhere must not ship an
642
+ // EMPTY description tag: `content=""` is a present-but-empty description,
643
+ // worse to a crawler than none, and the engine's own audit then fails the
644
+ // page it just decorated. Omit the description-bearing lines instead.
645
+ if (!pageDescription) {
646
+ seo = seo.replace(/^[^\n]*<meta (name="description"|property="og:description"|name="twitter:description")[^\n]*>\n?/gim, '');
647
+ }
204
648
  // image tags ship only when an og image exists (config-level or per-page)
205
649
  if (!ogImageFull) {
206
650
  seo = seo.replace(/^[^\n]*<meta (property="og:image"|name="twitter:image")[^\n]*>\n?/gim, '');
@@ -223,6 +667,15 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
223
667
  const hasJsonLd = hasSeoType(html, 'jsonld');
224
668
  const hasCanonical = hasSeoType(html, 'canonical');
225
669
 
670
+ // Per-tag missing-only within a SET: the og/twitter gates are group-level
671
+ // (og:title/og:description; twitter:card/twitter:title), but a page can ship
672
+ // a PARTIAL set - e.g. its own og:url with no og:title. The group gate is
673
+ // then false, the whole config set injects, and the page ends up with TWO
674
+ // og:url tags (the engine's own audit only counts og:title, so the duplicate
675
+ // shipped green). Filter each candidate tag against the page's own head.
676
+ const pageHasMeta = (attr, key) => new RegExp('<meta[^>]+' + attr + '=["\']'
677
+ + String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\']', 'i').test(html);
678
+
226
679
  let seoInjection = '';
227
680
  // FIX (was 0.4.x wart): plain description meta now really reaches bare pages
228
681
  if (!hasSeoType(html, 'description')) {
@@ -230,23 +683,60 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
230
683
  if (descMatch) seoInjection += '\n' + descMatch[0];
231
684
  }
232
685
  if (!hasOg) {
233
- const ogMatch = seo.match(/<meta property="og:[^"]*"[^>]*>/gi);
234
- if (ogMatch) seoInjection += '\n' + ogMatch.join('\n');
686
+ const ogTags = (seo.match(/<meta property="og:[^"]*"[^>]*>/gi) || [])
687
+ .filter((tag) => {
688
+ const key = (tag.match(/property=["'](og:[^"']*)["']/i) || [])[1];
689
+ // og:image is handled SLOT-level below (issue #46): a page carrying
690
+ // its own og basics still needs the config image, so the image is
691
+ // injected there for every page shape, never inside this group gate.
692
+ return key && key !== 'og:image' && !pageHasMeta('property', key);
693
+ });
694
+ if (ogTags.length) seoInjection += '\n' + ogTags.join('\n');
235
695
  // article:* companions for per-page article facts
236
696
  const articleTags = [];
237
- if (overrides?.publishedTime) articleTags.push('<meta property="article:published_time" content="' + escAttr(overrides.publishedTime) + '">');
238
- if (overrides?.author) articleTags.push('<meta property="article:author" content="' + escAttr(overrides.author) + '">');
239
- if (overrides?.section) articleTags.push('<meta property="article:section" content="' + escAttr(overrides.section) + '">');
697
+ if (overrides?.publishedTime && !pageHasMeta('property', 'article:published_time')) articleTags.push('<meta property="article:published_time" content="' + escAttr(overrides.publishedTime) + '">');
698
+ if (overrides?.author && !pageHasMeta('property', 'article:author')) articleTags.push('<meta property="article:author" content="' + escAttr(overrides.author) + '">');
699
+ if (overrides?.section && !pageHasMeta('property', 'article:section')) articleTags.push('<meta property="article:section" content="' + escAttr(overrides.section) + '">');
240
700
  if (articleTags.length) seoInjection += '\n' + articleTags.join('\n');
241
- } else if (overrides && [overrides.ogType, overrides.ogImage, overrides.publishedTime, overrides.author, overrides.section].some(v => v !== undefined)) {
701
+ } else if (overrides && [overrides.ogType, overrides.publishedTime, overrides.author, overrides.section].some(v => v !== undefined)) {
242
702
  pageWarn(page, 'page ships its own Open Graph tags; og/author per-page overrides were not applied');
243
703
  }
244
704
  if (!hasTwitter) {
245
- const twMatch = seo.match(/<meta name="twitter:[^"]*"[^>]*>/gi);
246
- if (twMatch) seoInjection += '\n' + twMatch.join('\n');
705
+ const twTags = (seo.match(/<meta name="twitter:[^"]*"[^>]*>/gi) || [])
706
+ .filter((tag) => {
707
+ const key = (tag.match(/name=["'](twitter:[^"']*)["']/i) || [])[1];
708
+ // twitter:image is SLOT-level, same rule as og:image (issue #46).
709
+ return key && key !== 'twitter:image' && !pageHasMeta('name', key);
710
+ });
711
+ if (twTags.length) seoInjection += '\n' + twTags.join('\n');
247
712
  } else if (overrides && overrides.twitterCard !== undefined) {
248
713
  pageWarn(page, 'page ships its own Twitter tags; the twitterCard override was not applied');
249
714
  }
715
+
716
+ // Image slots are SLOT-level, not set-level (issue #46): the og/twitter
717
+ // group gates above mark a page "covered" as soon as it ships its own
718
+ // og:title (or twitter:card), so a host layout that hand-writes its own og
719
+ // basics NEVER received the config og.image - the share image was starved on
720
+ // every page of such a site, while the canonical in the same head was
721
+ // correct. Inject the image (and its companions) whenever the page itself
722
+ // lacks og:image / twitter:image, independent of the group gate.
723
+ if (ogImageFull && !pageHasMeta('property', 'og:image')) {
724
+ seoInjection += '\n<meta property="og:image" content="' + escAttr(ogImageFull) + '">';
725
+ // issue #20: the structured og.image object form also emits its
726
+ // companions; only when the shipped image IS the config object - a
727
+ // per-page override ships its own image and replaces the set wholesale
728
+ // (companionsSource is null then).
729
+ if (companionsSource) {
730
+ const companions = [];
731
+ if (companionsSource.width !== undefined && companionsSource.width !== '') companions.push('<meta property="og:image:width" content="' + escAttr(companionsSource.width) + '">');
732
+ if (companionsSource.height !== undefined && companionsSource.height !== '') companions.push('<meta property="og:image:height" content="' + escAttr(companionsSource.height) + '">');
733
+ if (companionsSource.alt !== undefined && companionsSource.alt !== '') companions.push('<meta property="og:image:alt" content="' + escAttr(companionsSource.alt) + '">');
734
+ if (companions.length) seoInjection += '\n' + companions.join('\n');
735
+ }
736
+ }
737
+ if (ogImageFull && !pageHasMeta('name', 'twitter:image')) {
738
+ seoInjection += '\n<meta name="twitter:image" content="' + escAttr(ogImageFull) + '">';
739
+ }
250
740
  if (overrides && overrides.description !== undefined && hasSeoType(html, 'description')) {
251
741
  pageWarn(page, 'page has its own description meta; the description override was not applied');
252
742
  }
@@ -259,11 +749,54 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
259
749
  if (canMatch) seoInjection += '\n' + canMatch[0];
260
750
  }
261
751
 
752
+ // Feed autodiscovery (SEO-gap pass): feed.xml ships with honest dates,
753
+ // but nothing in any head pointed feed readers at it. One line in every
754
+ // injected head - gated on the page not carrying an alternate/rss link of
755
+ // its own, in either attribute order (missing-only, as always).
756
+ if (config.generateFeed !== false && !noindexExtrasGate
757
+ && !/(rel\s*=\s*["']alternate["'][^>]*type\s*=\s*["']application\/(rss|atom)\+xml["'])/i.test(html)
758
+ && !/(type\s*=\s*["']application\/(rss|atom)\+xml["'][^>]*rel\s*=\s*["']alternate["'])/i.test(html)) {
759
+ seoInjection += '\n<link rel="alternate" type="application/rss+xml" title="' + escAttr(siteName) + ' feed" href="' + escAttr(pageUrlFor(config, '/feed.xml')) + '">';
760
+ }
761
+
762
+ // Site verification metas (SEO-gap pass, config.verification): the
763
+ // provider -> code map drops straight into site-owned meta tags, with the
764
+ // engine-owned name spellings (search engines match on name, not content).
765
+ const VERIFICATION_META_NAMES = { google: 'google-site-verification', bing: 'msvalidate.01', yandex: 'yandexVerification', pinterest: 'pinterest-site-verification' };
766
+ if (config.verification && !noindexExtrasGate && typeof config.verification === 'object' && !Array.isArray(config.verification)) {
767
+ const verificationMetas = [];
768
+ for (const [provider, code] of Object.entries(config.verification)) {
769
+ const metaName = VERIFICATION_META_NAMES[provider];
770
+ if (!metaName || typeof code !== 'string' || code === '') continue; // config.js warned
771
+ // missing-only, attribute-ORDER tolerant: a page that writes its own
772
+ // verification meta in the other attribute order still suppresses ours
773
+ if (new RegExp('<meta[^>]*name=["\']' + metaName + '["\']', 'i').test(html)) continue;
774
+ verificationMetas.push('<meta name="' + metaName + '" content="' + escAttr(code) + '">');
775
+ }
776
+ if (verificationMetas.length) seoInjection += '\n' + verificationMetas.join('\n');
777
+ }
778
+
779
+ // hreflang/i18n alternates (SEO-gap pass, config.locales): slug-stem swap
780
+ // alternates for pages inside a locale tree; skipped entirely when the
781
+ // page carries any hreflang of its own (the SSG or the author owns it).
782
+ if (config.locales && !noindexExtrasGate && typeof config.locales === 'object' && !Array.isArray(config.locales)
783
+ && !/(\shreflang\s*=)/i.test(html)) {
784
+ const alternates = hreflangAlternatesFor(page, config);
785
+ if (alternates) {
786
+ seoInjection += '\n' + alternates
787
+ .map(([lang, url]) => '<link rel="alternate" hreflang="' + escAttr(lang) + '" href="' + escAttr(url) + '">')
788
+ .join('\n');
789
+ }
790
+ }
791
+
262
792
  const versionTag = '<meta name="freestruct-build" content="' + buildHash + '">';
263
793
  // Marker goes FIRST so the entire generated tail (seo block, later snippet
264
794
  // and late-pass insertions land downstream of it) is one strippable region
265
795
  // for computeBuildHash's stable view (scan.js) across reruns.
266
- html = html.replace(/<\/head>/i, '<!-- freestruct -->\n' + seoInjection + '\n' + versionTag + '\n</head>');
796
+ // Sequence 3, spot 2 (issue #24): seoInjection is config/page-carried, so
797
+ // it rides a FUNCTION replacer - in string form any $& / $1..9 inside the
798
+ // injected head would be treated as replacement syntax and rewrite the page.
799
+ html = html.replace(/<\/head>/i, () => '<!-- freestruct -->\n' + seoInjection + '\n' + versionTag + '\n</head>');
267
800
 
268
801
  // Custom snippet passes: comments stripped, advertised placeholders
269
802
  // ({{siteName}}, {{siteUrl}}, {{buildHash}}, ...) substituted - FIX for
@@ -273,20 +806,29 @@ export function injectPage(page, { config, template, buildHash, snippetDir }) {
273
806
  if (!fs.existsSync(snippetPath)) return null;
274
807
  let snippet = fs.readFileSync(snippetPath, 'utf8');
275
808
  for (const [k, v] of Object.entries(replacements)) snippet = snippet.split(k).join(v);
276
- return snippet.replace(/<!--[\s\S]*?-->/g, '');
809
+ const stripped = snippet.replace(/<!--[\s\S]*?-->/g, '');
810
+ // The shipped snippet templates are comments-only (placeholders/docs for
811
+ // the consumer to edit), so they strip to whitespace. Injecting that wraps
812
+ // an empty region in the markers on EVERY page (and the footer's dozen
813
+ // blank lines): treat a comments-only snippet as absent, not as an empty
814
+ // injection.
815
+ return stripped.trim() === '' ? null : stripped;
277
816
  }
278
817
 
279
- // Custom header injection before </head>
818
+ // Snippet bodies (issue #24): snippet/config-carried text always rides
819
+ // function replacers - a literal $ sequence in a snippet must print
820
+ // literally, never interfere with the surrounding document.
280
821
  const header = readSnippet('inject-header.html');
281
- if (header) html = html.replace(/<\/head>/i, header + '\n</head>');
822
+ if (header) html = html.replace(/<\/head>/i, () => header + '\n</head>');
282
823
 
283
- // Custom body-start injection after <body>
824
+ // Custom body-start injection after <body>. Marker-wrapped so a rerun can
825
+ // drop the previous copy (stripInjectedRegions) instead of stacking it.
284
826
  const bodyStart = readSnippet('inject-body-start.html');
285
- if (bodyStart) html = html.replace(/<body[^>]*>/i, '$&' + bodyStart);
827
+ if (bodyStart) html = html.replace(/<body[^>]*>/i, (m) => m + SNIPPET_OPEN + bodyStart + SNIPPET_CLOSE);
286
828
 
287
- // Custom footer injection before </body>
829
+ // Custom footer injection before </body> (marker-wrapped, same reason).
288
830
  const footer = readSnippet('inject-footer.html');
289
- if (footer) html = html.replace(/<\/body>/i, footer + '\n</body>');
831
+ if (footer) html = html.replace(/<\/body>/i, () => SNIPPET_OPEN + footer + SNIPPET_CLOSE + '</body>');
290
832
 
291
833
  if (config.minify) html = minifyHtml(html);
292
834
 
@@ -302,7 +844,11 @@ export function addReadingTime(filePath) {
302
844
  const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
303
845
  if (!bodyMatch) return;
304
846
 
305
- const text = bodyMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
847
+ // Script/style bodies and comments are not page prose: counting them
848
+ // inflated the estimate on any page with an inline bundle or a CSS block
849
+ // (the docs site's own pages carry both). htmlToText strips them, then tags,
850
+ // then collapses whitespace - the same view the search index reads.
851
+ const text = htmlToText(bodyMatch[1]);
306
852
  const words = text.split(/\s+/).filter(w => w.length > 0);
307
853
  const minutes = Math.max(1, Math.ceil(words.length / 200));
308
854
 
@@ -324,8 +870,73 @@ export function addLastModified(filePath) {
324
870
  export function addLazyLoading(filePath) {
325
871
  let html = fs.readFileSync(filePath, 'utf8');
326
872
  html = html.replace(/(<img[^>]*(?<!loading)=)[^>]*>/gi, (match) => {
327
- if (match.includes('loading=')) return match;
328
- return match.replace(/>$/, ' loading="lazy">');
873
+ // Case-insensitive, whitespace-tolerant guard: HTML attribute names are
874
+ // case-insensitive and `loading = "eager"` is valid, so a lowercase-only
875
+ // check let `<img ... LOADING="eager">` collect a SECOND loading attribute.
876
+ if (/loading\s*=/i.test(match)) return match;
877
+ // Insert BEFORE the closing `>` (and before a self-closing `/>`), never
878
+ // between the slash and the bracket - the old `/>`-unaware append left a
879
+ // dangling `/ loading="lazy">` on `<img ... />` (valid-ish to a browser,
880
+ // malformed markup everywhere else).
881
+ return match.replace(/\s*\/?>$/, ' loading="lazy">');
329
882
  });
330
883
  fs.writeFileSync(filePath, html);
331
884
  }
885
+
886
+ /**
887
+ * Wrap every <table> in a scroll container so a wide table scrolls inside its
888
+ * own box instead of pinning the whole page to the table's min-content width
889
+ * (issue #35: on a ~426px phone a wide docs table clipped the H1, the nav and
890
+ * everything else). The wrapper carries the class (for consumer theming) plus
891
+ * the scroll properties inline, so the guard holds by construction on any
892
+ * layout, including ones whose CSS predates the rule.
893
+ *
894
+ * Depth-aware so nested tables wrap once (the outermost); idempotent (a table
895
+ * already inside a table-ish wrapper is skipped, so runs never stack); and it
896
+ * leaves the tree untouched when there is no table to wrap (no rewrite, no
897
+ * spurious build-hash flip).
898
+ */
899
+ export function addTableWrap(filePath) {
900
+ let html = fs.readFileSync(filePath, 'utf8');
901
+ if (!/<table[\s>]/i.test(html)) return;
902
+
903
+ // Depth-aware span scan: record [start, end) of each OUTERMOST table block.
904
+ const tagRe = /<table\b[^>]*>|<\/table\s*>/gi;
905
+ const spans = [];
906
+ let depth = 0;
907
+ let start = -1;
908
+ let m;
909
+ while ((m = tagRe.exec(html)) !== null) {
910
+ if (/^<\/table/i.test(m[0])) {
911
+ if (depth > 0) {
912
+ depth--;
913
+ if (depth === 0) spans.push([start, tagRe.lastIndex]);
914
+ }
915
+ } else {
916
+ if (depth === 0) start = m.index;
917
+ depth++;
918
+ }
919
+ }
920
+
921
+ // Skip a table whose immediate ancestor is already a table-style wrapper
922
+ // (ours from a prior pass, or a consumer's own .table-wrap/.table-responsive).
923
+ // "table" must be a DELIMITED class segment: "printable"/"portable" do not
924
+ // count, so a table after an unrelated div still wraps.
925
+ const wrapped = /<div\b[^>]*\bclass=["']([^"']*)["'][^>]*>\s*$/i;
926
+ const tableish = (cls) => cls.split(/\s+/).some((tok) => /^table([-_]|$)|[-_]table([-_]|$)/i.test(tok));
927
+ const todo = spans.filter(([s]) => {
928
+ const mm = html.slice(0, s).match(wrapped);
929
+ return !(mm && tableish(mm[1]));
930
+ });
931
+ if (todo.length === 0) return;
932
+
933
+ // Insert from the end backward so earlier offsets stay valid.
934
+ let out = html;
935
+ for (let i = todo.length - 1; i >= 0; i--) {
936
+ const [s, e] = todo[i];
937
+ out = out.slice(0, s)
938
+ + '<div class="fs-table-wrap" style="overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%">'
939
+ + out.slice(s, e) + '</div>' + out.slice(e);
940
+ }
941
+ fs.writeFileSync(filePath, out);
942
+ }