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/README.md +135 -119
- package/engine/_freestruct/inject-brand.html +1 -5
- package/engine/audit.js +487 -0
- package/engine/config.js +208 -1
- package/engine/hooks.js +81 -15
- package/engine/indexnow.js +149 -0
- package/engine/inject.js +303 -22
- package/engine/linkcheck.js +83 -11
- package/engine/llms.js +63 -0
- package/engine/notfound.js +21 -5
- package/engine/ogimage.js +142 -0
- package/engine/scan.js +155 -20
- package/engine/search.js +12 -9
- package/engine/seo.js +682 -71
- package/engine/urls.js +48 -1
- package/engine/xmllike.js +7 -4
- package/init/README.md +6 -20
- package/init/VERSION +1 -1
- package/init/docs/_freestruct/inject-brand.html +1 -5
- package/init/docs/freestruct.config.yml +16 -0
- package/package.json +9 -2
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
|
-
//
|
|
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();
|
|
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 & from the SSG.
|
|
48
|
-
function escAttr(value) {
|
|
200
|
+
export function escAttr(value) {
|
|
49
201
|
return String(value == null ? '' : value)
|
|
50
202
|
.replace(/</g, '<')
|
|
51
203
|
.replace(/>/g, '>')
|
|
@@ -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
|
-
|
|
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
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
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
|
-
:
|
|
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
|
-
?
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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;
|
|
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):
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
234
|
-
|
|
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.
|
|
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
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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 + '
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
+
}
|