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/README.md +111 -189
- package/engine/audit.js +168 -36
- package/engine/config.js +1 -0
- package/engine/hooks.js +15 -7
- package/engine/indexnow.js +11 -2
- package/engine/inject.js +113 -43
- package/engine/linkcheck.js +38 -6
- package/engine/llms.js +12 -1
- package/engine/notfound.js +21 -5
- package/engine/scan.js +135 -9
- package/engine/search.js +12 -9
- package/engine/seo.js +347 -71
- package/engine/xmllike.js +7 -4
- package/init/README.md +6 -20
- package/init/VERSION +1 -1
- package/init/docs/freestruct.config.yml +16 -0
- package/package.json +6 -2
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
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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: (
|
|
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 (!
|
|
126
|
-
|
|
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 & from the SSG.
|
|
181
|
-
function escAttr(value) {
|
|
200
|
+
export function escAttr(value) {
|
|
182
201
|
return String(value == null ? '' : value)
|
|
183
202
|
.replace(/</g, '<')
|
|
184
203
|
.replace(/>/g, '>')
|
|
@@ -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
|
-
?
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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><link href="/style.css"></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 =
|
|
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
|
|
514
|
-
|
|
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
|
-
|
|
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 (!
|
|
535
|
-
|
|
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
|
-
|
|
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 + '
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
89
|
+
// Entity-DECODED extraction: the SSG's '&' must reach escXml as a raw
|
|
90
|
+
// '&' so it escapes ONCE. Reading the raw match and re-escaping double-
|
|
91
|
+
// encoded the text ('&amp;', which renders literally as '&').
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
1.2.0
|