freestruct 1.1.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,34 +7,38 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import { hasSeoType, htmlIsNoindex } from './scan.js';
10
+ import { hasSeoType, htmlIsNoindex, htmlToText } from './scan.js';
11
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();
38
42
  }
39
43
 
40
44
  // ---------- SEO-gap pass helpers (feed link / SearchAction / breadcrumb / article / hreflang) ----------
@@ -54,8 +58,7 @@ function stemForPattern(pattern) {
54
58
  return rootHomeSlug;
55
59
  }
56
60
 
57
- // Root-home retries: a rest of '/
58
- ' or '/' is the home page, not '//'.
61
+ // Root-home retries: a rest of '/' or '' is the home page, not '//'.
59
62
  function slugForHomePage(slug) { return slug === rootHomeSlug ? rootHomeSlug : slug; }
60
63
 
61
64
  /**
@@ -94,10 +97,15 @@ function breadcrumbJsonLd(page, config) {
94
97
  let acc = '';
95
98
  segs.forEach((seg, i) => {
96
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;
97
105
  items.push({
98
106
  position: i + 2,
99
- name: (i === segs.length - 1 && page.title) ? page.title : seg.replace(/[-_]/g, ' '),
100
- url: pageUrlFor(config, acc),
107
+ name: (isLeaf && page.title) ? page.title : seg.replace(/[-_]/g, ' '),
108
+ url: pageUrlFor(config, isLeaf ? page.slug : acc),
101
109
  });
102
110
  });
103
111
  return {
@@ -112,6 +120,20 @@ function breadcrumbJsonLd(page, config) {
112
120
  };
113
121
  }
114
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));
135
+ }
136
+
115
137
  /**
116
138
  * Does this page RECEIVE a scoped jsonLd block? The scoped map form keys
117
139
  * slug globs to block lists (config.js reads legacy forms - a schema object
@@ -122,11 +144,8 @@ function breadcrumbJsonLd(page, config) {
122
144
  */
123
145
  function pageReceivesScopedJsonLd(page, config) {
124
146
  const jsonLd = config.jsonLd;
125
- if (!jsonLd || typeof jsonLd !== 'object' || Array.isArray(jsonLd)) return false;
126
- const keys = Object.keys(jsonLd);
127
- if (keys.length === 0) return false;
128
- if (!keys.every((k) => typeof k === 'string' && k.startsWith('/'))) return false;
129
- return keys.some((k) => slugMatchesGlob(k, page.slug));
147
+ if (!isScopedJsonLdMap(jsonLd)) return false;
148
+ return Object.keys(jsonLd).some((k) => slugMatchesGlob(k, page.slug));
130
149
  }
131
150
 
132
151
  /**
@@ -178,7 +197,7 @@ function hreflangAlternatesFor(page, config) {
178
197
  // '&' is deliberately NOT escaped: the battery pins the raw ampersand in
179
198
  // canonical attributes as valid HTML, and escaping it would double-encode
180
199
  // legit URLs that already contain &amp; from the SSG.
181
- function escAttr(value) {
200
+ export function escAttr(value) {
182
201
  return String(value == null ? '' : value)
183
202
  .replace(/</g, '&lt;')
184
203
  .replace(/>/g, '&gt;')
@@ -239,10 +258,39 @@ function readPageOverrides(html, page) {
239
258
  * pages that ship their own OG/Twitter/JSON-LD/canonical/description keep them.
240
259
  * Page-level freestruct comments refine the injected tags (see PERPAGE_KEYS).
241
260
  */
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
+
242
287
  export function injectPage(page, { config, template, buildHash, snippetDir, injectWarnings, generatedImages }) {
243
288
  let html = page.html;
244
289
  const siteName = config.site.name;
245
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);
246
294
  const overrides = readPageOverrides(html, page);
247
295
 
248
296
  // Headless-page diagnostics (Pass 26.13): every injected surface anchors
@@ -322,7 +370,7 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
322
370
  ? (scopedIsObject ? String(scopedForPage.src || '') : String(scopedForPage))
323
371
  : (ogImageObj ? String(ogImageObj.src || '') : (ogImageCfg || ''))));
324
372
  const ogImageFull = rawOgImage && !/^(https?:)?\/\//i.test(rawOgImage)
325
- ? siteUrl + rawOgImage
373
+ ? pageUrlFor(config, rawOgImage)
326
374
  : rawOgImage;
327
375
 
328
376
  // issue #20, the SVG trap: an SVG og:image cannot be rendered by most
@@ -340,16 +388,127 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
340
388
 
341
389
  // Add cache-busting query param to assets (css, js, images, fonts, ...)
342
390
  if (config.cacheBusting?.assetQueryParam !== false) {
343
- const assetExts = '.css.js.png.jpg.jpeg.gif.svg.webp.ico.woff.woff2.ttf.otf.wasm.json';
344
- html = html.replace(/(href|src)=\"([^\"]+)\"/gi,
345
- (match, attr, url) => {
346
- const cleanUrl = url.replace(/\?v=[^"]*/, '');
347
- const ext = cleanUrl.substring(cleanUrl.lastIndexOf('.'));
348
- if (assetExts.includes(ext)) {
349
- return attr + '=\"' + cleanUrl + '?v=' + buildHash + '\"';
350
- }
351
- 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;
352
510
  });
511
+ html = html.replace(/\u0000FS(\d+)\u0000/g, (m, i) => masks[Number(i)]);
353
512
  }
354
513
 
355
514
  // SearchAction (config.search.url, SEO-gap pass): the seam expands to ''
@@ -402,9 +561,7 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
402
561
  let customJsonLd = '';
403
562
  if (config.jsonLd !== undefined) {
404
563
  const jc = config.jsonLd;
405
- const isScopedMap = !Array.isArray(jc) && jc && typeof jc === 'object'
406
- && Object.keys(jc).length > 0
407
- && Object.keys(jc).every((k) => /(^\/|^\*$|^\*\*$|\*)/.test(k));
564
+ const isScopedMap = isScopedJsonLdMap(jc);
408
565
  if (isScopedMap) {
409
566
  const keys = Object.keys(jc).sort((a, b) => globSpecificity(b) - globSpecificity(a));
410
567
  for (const key of keys) {
@@ -481,6 +638,13 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
481
638
  let seo = template;
482
639
  for (const [k, v] of Object.entries(replacements)) seo = seo.split(k).join(v);
483
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
+ }
484
648
  // image tags ship only when an og image exists (config-level or per-page)
485
649
  if (!ogImageFull) {
486
650
  seo = seo.replace(/^[^\n]*<meta (property="og:image"|name="twitter:image")[^\n]*>\n?/gim, '');
@@ -503,6 +667,15 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
503
667
  const hasJsonLd = hasSeoType(html, 'jsonld');
504
668
  const hasCanonical = hasSeoType(html, 'canonical');
505
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
+
506
679
  let seoInjection = '';
507
680
  // FIX (was 0.4.x wart): plain description meta now really reaches bare pages
508
681
  if (!hasSeoType(html, 'description')) {
@@ -510,32 +683,59 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
510
683
  if (descMatch) seoInjection += '\n' + descMatch[0];
511
684
  }
512
685
  if (!hasOg) {
513
- const ogMatch = seo.match(/<meta property="og:[^"]*"[^>]*>/gi);
514
- 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');
695
+ // article:* companions for per-page article facts
696
+ const articleTags = [];
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) + '">');
700
+ if (articleTags.length) seoInjection += '\n' + articleTags.join('\n');
701
+ } else if (overrides && [overrides.ogType, overrides.publishedTime, overrides.author, overrides.section].some(v => v !== undefined)) {
702
+ pageWarn(page, 'page ships its own Open Graph tags; og/author per-page overrides were not applied');
703
+ }
704
+ if (!hasTwitter) {
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');
712
+ } else if (overrides && overrides.twitterCard !== undefined) {
713
+ pageWarn(page, 'page ships its own Twitter tags; the twitterCard override was not applied');
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) + '">';
515
725
  // issue #20: the structured og.image object form also emits its
516
726
  // companions; only when the shipped image IS the config object - a
517
- // per-page override ships its own image and replaces the set wholesale.
518
- if (ogImageFull && companionsSource && !hasPerpageOgImage) {
727
+ // per-page override ships its own image and replaces the set wholesale
728
+ // (companionsSource is null then).
729
+ if (companionsSource) {
519
730
  const companions = [];
520
731
  if (companionsSource.width !== undefined && companionsSource.width !== '') companions.push('<meta property="og:image:width" content="' + escAttr(companionsSource.width) + '">');
521
732
  if (companionsSource.height !== undefined && companionsSource.height !== '') companions.push('<meta property="og:image:height" content="' + escAttr(companionsSource.height) + '">');
522
733
  if (companionsSource.alt !== undefined && companionsSource.alt !== '') companions.push('<meta property="og:image:alt" content="' + escAttr(companionsSource.alt) + '">');
523
734
  if (companions.length) seoInjection += '\n' + companions.join('\n');
524
735
  }
525
- // article:* companions for per-page article facts
526
- const articleTags = [];
527
- if (overrides?.publishedTime) articleTags.push('<meta property="article:published_time" content="' + escAttr(overrides.publishedTime) + '">');
528
- if (overrides?.author) articleTags.push('<meta property="article:author" content="' + escAttr(overrides.author) + '">');
529
- if (overrides?.section) articleTags.push('<meta property="article:section" content="' + escAttr(overrides.section) + '">');
530
- if (articleTags.length) seoInjection += '\n' + articleTags.join('\n');
531
- } else if (overrides && [overrides.ogType, overrides.ogImage, overrides.publishedTime, overrides.author, overrides.section].some(v => v !== undefined)) {
532
- pageWarn(page, 'page ships its own Open Graph tags; og/author per-page overrides were not applied');
533
736
  }
534
- if (!hasTwitter) {
535
- const twMatch = seo.match(/<meta name="twitter:[^"]*"[^>]*>/gi);
536
- if (twMatch) seoInjection += '\n' + twMatch.join('\n');
537
- } else if (overrides && overrides.twitterCard !== undefined) {
538
- pageWarn(page, 'page ships its own Twitter tags; the twitterCard override was not applied');
737
+ if (ogImageFull && !pageHasMeta('name', 'twitter:image')) {
738
+ seoInjection += '\n<meta name="twitter:image" content="' + escAttr(ogImageFull) + '">';
539
739
  }
540
740
  if (overrides && overrides.description !== undefined && hasSeoType(html, 'description')) {
541
741
  pageWarn(page, 'page has its own description meta; the description override was not applied');
@@ -606,7 +806,13 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
606
806
  if (!fs.existsSync(snippetPath)) return null;
607
807
  let snippet = fs.readFileSync(snippetPath, 'utf8');
608
808
  for (const [k, v] of Object.entries(replacements)) snippet = snippet.split(k).join(v);
609
- 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;
610
816
  }
611
817
 
612
818
  // Snippet bodies (issue #24): snippet/config-carried text always rides
@@ -615,13 +821,14 @@ export function injectPage(page, { config, template, buildHash, snippetDir, inje
615
821
  const header = readSnippet('inject-header.html');
616
822
  if (header) html = html.replace(/<\/head>/i, () => header + '\n</head>');
617
823
 
618
- // 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.
619
826
  const bodyStart = readSnippet('inject-body-start.html');
620
- if (bodyStart) html = html.replace(/<body[^>]*>/i, (m) => m + bodyStart);
827
+ if (bodyStart) html = html.replace(/<body[^>]*>/i, (m) => m + SNIPPET_OPEN + bodyStart + SNIPPET_CLOSE);
621
828
 
622
- // Custom footer injection before </body>
829
+ // Custom footer injection before </body> (marker-wrapped, same reason).
623
830
  const footer = readSnippet('inject-footer.html');
624
- if (footer) html = html.replace(/<\/body>/i, () => footer + '\n</body>');
831
+ if (footer) html = html.replace(/<\/body>/i, () => SNIPPET_OPEN + footer + SNIPPET_CLOSE + '</body>');
625
832
 
626
833
  if (config.minify) html = minifyHtml(html);
627
834
 
@@ -637,7 +844,11 @@ export function addReadingTime(filePath) {
637
844
  const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
638
845
  if (!bodyMatch) return;
639
846
 
640
- 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]);
641
852
  const words = text.split(/\s+/).filter(w => w.length > 0);
642
853
  const minutes = Math.max(1, Math.ceil(words.length / 200));
643
854
 
@@ -659,8 +870,73 @@ export function addLastModified(filePath) {
659
870
  export function addLazyLoading(filePath) {
660
871
  let html = fs.readFileSync(filePath, 'utf8');
661
872
  html = html.replace(/(<img[^>]*(?<!loading)=)[^>]*>/gi, (match) => {
662
- if (match.includes('loading=')) return match;
663
- 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">');
664
882
  });
665
883
  fs.writeFileSync(filePath, html);
666
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
+ }
package/engine/xmllike.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { pageUrlFor } from './urls.js';
11
+ import { extractTitle, extractDescription } from './scan.js';
11
12
 
12
13
  /** Escape a value for XML text/attribute contexts. */
13
14
  export function escXml(value) {
@@ -85,10 +86,12 @@ export function generateFeed(pages, config, outputDir) {
85
86
  let items = '';
86
87
  for (const page of pages) {
87
88
  const html = fs.readFileSync(page.file, 'utf8');
88
- const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
89
- const descMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
90
- const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file).replace(/\.html?$/i, '');
91
- const desc = descMatch ? descMatch[1] : '';
89
+ // Entity-DECODED extraction: the SSG's '&amp;' must reach escXml as a raw
90
+ // '&' so it escapes ONCE. Reading the raw match and re-escaping double-
91
+ // encoded the text ('&amp;amp;', which renders literally as '&amp;').
92
+ const extracted = extractTitle(html);
93
+ const title = extracted || path.basename(page.file).replace(/\.html?$/i, '');
94
+ const desc = extractDescription(html) || '';
92
95
  const pageUrl = pageUrlFor(config, page.slug);
93
96
  const pubDate = page.mtime.toUTCString();
94
97
  items += '\n<item>\n'
package/init/README.md CHANGED
@@ -1,10 +1,6 @@
1
1
  # Freestruct init kit
2
2
 
3
- This is the consumer scaffold kit (prestruct's `init/` pattern, sized to
4
- Freestruct): the minimum a brand-new project copies in to be Freestruct-ready
5
- in minutes. Freestruct runs AFTER your static site generator builds, so the
6
- kit is deliberately SSG-agnostic - your Jekyll/Hugo/Docusaurus/MkDocs setup
7
- stays untouched.
3
+ This is the consumer scaffold kit (prestruct's `init/` pattern, sized to Freestruct): the minimum a brand-new project copies in to be Freestruct-ready in minutes. Freestruct runs AFTER your static site generator builds, so the kit is deliberately SSG-agnostic - your Jekyll/Hugo/Docusaurus/MkDocs setup stays untouched.
8
4
 
9
5
  ## What's in the kit
10
6
 
@@ -28,21 +24,11 @@ cp -r node_modules/freestruct/init/docs .
28
24
 
29
25
  Then:
30
26
 
31
- 1. Edit `docs/freestruct.config.yml`: `site.name` and `site.url` are required;
32
- adjust `site.description` to fit.
33
- 2. Let your SSG build. `npx freestruct` discovers the output dir
34
- (default `docs/_site`; override via the `outputDir` config key or a
35
- positional CLI argument).
36
- 3. Verify: `docs/_site` gains `sitemap.xml`, `robots.txt`, `feed.xml` and
37
- (when missing) a `404.html`; injected pages carry canonical/OG tags and a
38
- `freestruct-build` marker.
39
- 4. Customize templates only if you need to: the packaged engine templates are
40
- used automatically when no staged copy exists. To customize, edit
41
- `docs/_freestruct/` copies (which you just copied in) - a staged template
42
- always wins over the package-shipped one.
27
+ 1. Edit `docs/freestruct.config.yml`: `site.name` and `site.url` are required; adjust `site.description` to fit. The starter also seeds the family identity - an `author` block (the Creadev.org founder) and a `jsonLd` maintainer Person - so scaffolded ports ship ownership from birth; set the maintainer handle to your own.
28
+ 2. Let your SSG build. `npx freestruct` discovers the output dir (default `docs/_site`; override via the `outputDir` config key or a positional CLI argument).
29
+ 3. Verify: `docs/_site` gains `sitemap.xml`, `robots.txt`, `feed.xml` and (when missing) a `404.html`; injected pages carry canonical/OG tags and a `freestruct-build` marker.
30
+ 4. Customize templates only if you need to: the packaged engine templates are used automatically when no staged copy exists. To customize, edit `docs/_freestruct/` copies (which you just copied in) - a staged template always wins over the package-shipped one.
43
31
 
44
32
  ## Why a kit (not a lucky guess)
45
33
 
46
- Copying this kit means your first run exercises the exact shape the test
47
- battery and the tarball e2e verify: staged config + staged templates next to
48
- the docs folder, discovered by walking up from your project root.
34
+ Copying this kit means your first run exercises the exact shape the test battery and the tarball e2e verify: staged config + staged templates next to the docs folder, discovered by walking up from your project root.
package/init/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.2.0