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/audit.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// and remapped onto freestruct's own contract:
|
|
6
6
|
// - meta extraction is attribute-order tolerant (per the donor's notes)
|
|
7
7
|
// - og:image / twitter:image / twitter:site/creator are config-gated in
|
|
8
|
-
// this engine (missing-only), so their absence is a WARN, not a FAIL
|
|
8
|
+
// this engine (missing-only), so their absence is a WARN, not a FAIL.
|
|
9
|
+
// The og:image / twitter:image absences are CONFIG-shaped (identical on
|
|
10
|
+
// every page), so they collapse to one line each with a page count
|
|
11
|
+
// (issue #28); twitter:site/creator are graded only when a handle is set.
|
|
9
12
|
// - the FAIL og/twitter class is the SHARED core (og:title/og:description,
|
|
10
13
|
// twitter:card/twitter:title); the rest of the injected set rides WARN
|
|
11
14
|
// because missing-only means SSG-tagged pages legitimately keep a
|
|
@@ -21,6 +24,7 @@
|
|
|
21
24
|
|
|
22
25
|
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
23
26
|
import path from 'path';
|
|
27
|
+
import { decodeEntities } from './scan.js';
|
|
24
28
|
|
|
25
29
|
// FAIL class = the tags core to the engine's own contract. The REST of the
|
|
26
30
|
// injected set (og:url/type/site_name/locale, twitter:description) rides as
|
|
@@ -37,19 +41,34 @@ const LB_RECOMMENDED = ['name', 'url', 'image', 'logo', 'telephone', 'priceRange
|
|
|
37
41
|
|
|
38
42
|
/** Attribute-order tolerant meta extraction: hits aligned multi-space output. */
|
|
39
43
|
function grabMeta(head, attrHasKey, name) {
|
|
40
|
-
|
|
44
|
+
// `<meta\b[^>]*` (not `<meta[^>]`) so a leading attribute - `<meta charset=...
|
|
45
|
+
// name="description">` - is found too. The single-char class made the audit
|
|
46
|
+
// false-FAIL "missing meta description" on any page whose description meta
|
|
47
|
+
// carried another attribute first.
|
|
48
|
+
const keyRe = new RegExp(`<meta\\b[^>]*\\b${attrHasKey}=["']${name}["'][^>]*>`, 'i');
|
|
41
49
|
const tag = head.match(keyRe);
|
|
42
50
|
if (!tag) return null;
|
|
43
|
-
const content = tag[0]
|
|
44
|
-
return content
|
|
51
|
+
const content = attrValue(tag[0], 'content');
|
|
52
|
+
return content;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Quoted-attribute reader. The naive `["']([^"']*)["']` stopped at the FIRST
|
|
56
|
+
// quote of either kind, so any value holding the other quote (a description
|
|
57
|
+
// with an apostrophe - "this repo's docs") was truncated at the apostrophe.
|
|
58
|
+
// The backreference forces the closing quote to match the opening one, so the
|
|
59
|
+
// value survives intact. Unquoted values fall through to a bare read.
|
|
60
|
+
function attrValue(tag, attr) {
|
|
61
|
+
const quoted = tag.match(new RegExp(`\\b${attr}=(["'])([\\s\\S]*?)\\1`, 'i'));
|
|
62
|
+
if (quoted) return quoted[2];
|
|
63
|
+
const bare = tag.match(new RegExp(`\\b${attr}=([^\\s>]+)`, 'i'));
|
|
64
|
+
return bare ? bare[1] : null;
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
function grabCanonical(head) {
|
|
48
68
|
const link = head.match(/<link[^>]+rel=["']canonical["'][^>]*>/i)
|
|
49
|
-
|| head.match(/<link[^>]+href=["']
|
|
69
|
+
|| head.match(/<link[^>]+href=["'][^"']+["'][^>]+rel=["']canonical["'][^>]*>/i);
|
|
50
70
|
if (!link) return null;
|
|
51
|
-
|
|
52
|
-
return href ? href[1] : null;
|
|
71
|
+
return attrValue(link[0], 'href');
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
/** Count every occurrence of a head shape - the duplicate-class probe. */
|
|
@@ -68,11 +87,28 @@ function walkHtml(dir) {
|
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
function stripTags(s) {
|
|
71
|
-
return String(s).replace(/<[^>]+>/g, '').
|
|
90
|
+
return String(s).replace(/<[^>]+>/g, '').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Title/description length is graded against what a SEARCH ENGINE sees: the
|
|
94
|
+
// DECODED text, not the raw markup. An entity-heavy title ('&', ''',
|
|
95
|
+
// 'A') inflated the raw length and tripped the > 65 / > 165 warnings on
|
|
96
|
+
// text that renders well inside the band.
|
|
97
|
+
function decodedLength(s) {
|
|
98
|
+
return decodeEntities(stripTags(s)).length;
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Head-referenced site-local URL -> { resolved, inside } | null.
|
|
103
|
+
*
|
|
104
|
+
* null means "not a site-local path" (external host, protocol-relative,
|
|
105
|
+
* relative, empty) - nothing on disk to check. `inside` is false when the
|
|
106
|
+
* path resolves OUTSIDE the output tree: a traversal shape like
|
|
107
|
+
* '/../../etc/hostname', which must never be probed as if it were an
|
|
108
|
+
* artifact. (Before Pass 26.21 urlToDisk resolved and existsSync'd such a
|
|
109
|
+
* path, so a value pointing at any file on the host read back as "exists".)
|
|
110
|
+
*/
|
|
111
|
+
function sitePathInfo(urlValue, { outputDir, config }) {
|
|
76
112
|
if (!urlValue) return null;
|
|
77
113
|
let pathName = urlValue.split(/[?#]/)[0];
|
|
78
114
|
if (/^https?:\/\//i.test(pathName)) {
|
|
@@ -86,13 +122,20 @@ function urlToDisk(urlValue, { outputDir, config }) {
|
|
|
86
122
|
} else if (/^\/\//.test(pathName)) {
|
|
87
123
|
return null;
|
|
88
124
|
}
|
|
89
|
-
if (pathName.startsWith('/'))
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
125
|
+
if (!pathName.startsWith('/')) return null;
|
|
126
|
+
// basePath normalization mirrors urls.js (issue #32 contract): the
|
|
127
|
+
// configured prefix may lack its leading slash, and a prefix must match a
|
|
128
|
+
// whole path segment ('/myproject' never strips '/myprojectx/...').
|
|
129
|
+
const bp = config?.basePath
|
|
130
|
+
? ('/' + String(config.basePath).replace(/^\/+/, '')).replace(/\/+$/, '')
|
|
131
|
+
: '';
|
|
132
|
+
if (bp && (pathName === bp || pathName.startsWith(bp + '/'))) {
|
|
133
|
+
pathName = pathName.slice(bp.length);
|
|
94
134
|
}
|
|
95
|
-
|
|
135
|
+
const resolved = path.resolve(outputDir, pathName.replace(/^\//, ''));
|
|
136
|
+
const root = path.resolve(outputDir);
|
|
137
|
+
const inside = resolved === root || resolved.startsWith(root + path.sep);
|
|
138
|
+
return { resolved, inside };
|
|
96
139
|
}
|
|
97
140
|
|
|
98
141
|
// ---------- the audit ----------
|
|
@@ -110,6 +153,19 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
110
153
|
const fail = (page, msg) => fails.push({ page, msg });
|
|
111
154
|
const warn = (page, msg) => warns.push({ page, msg });
|
|
112
155
|
|
|
156
|
+
// Config-gated absences (issue #28): a condition that can only ever be
|
|
157
|
+
// true/false at the CONFIG level repeats identically on every page
|
|
158
|
+
// (og:image / twitter:image absent because no og.image is configured).
|
|
159
|
+
// Collect affected pages here and flush ONE line each with a count at the
|
|
160
|
+
// end, instead of N page-suffixed rows that bury the real findings.
|
|
161
|
+
// Page-shaped classes (title/description length, JSON-LD absence, h1
|
|
162
|
+
// sanity) still repeat - their findings genuinely differ per page.
|
|
163
|
+
const configGated = new Map(); // key -> Set(page labels)
|
|
164
|
+
const configGate = (key, label) => {
|
|
165
|
+
if (!configGated.has(key)) configGated.set(key, new Set());
|
|
166
|
+
configGated.get(key).add(label);
|
|
167
|
+
};
|
|
168
|
+
|
|
113
169
|
// File-at-path hardening (Pass 26.13): a file at the outputDir path passes
|
|
114
170
|
// existsSync, and walkHtml's readdirSync would arrive here uncaught.
|
|
115
171
|
if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) {
|
|
@@ -137,15 +193,24 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
137
193
|
const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
138
194
|
if (!titleMatch || !titleMatch[1].trim()) fail(label, 'missing <title>');
|
|
139
195
|
else {
|
|
140
|
-
const t =
|
|
196
|
+
const t = decodedLength(titleMatch[1]);
|
|
141
197
|
if (t > 65) warn(label, 'title ' + t + ' chars (> 65, may truncate)');
|
|
142
198
|
if (t < 10) warn(label, 'title ' + t + ' chars (suspiciously short)');
|
|
143
199
|
}
|
|
144
200
|
|
|
201
|
+
// A page that declares `robots noindex` is deliberately out of the index
|
|
202
|
+
// (a redirect stub, a thank-you page): the content-quality classes below
|
|
203
|
+
// - description length band, h1 shape, JSON-LD absence - grade indexable
|
|
204
|
+
// content, so they would only emit cosmetic noise on a page whose whole
|
|
205
|
+
// point is to not rank. The structural classes (title/canonical presence,
|
|
206
|
+
// duplicate tags, absolute canonical) still apply.
|
|
207
|
+
const noindex = /noindex/i.test(grabMeta(head, 'name', 'robots') || '');
|
|
208
|
+
|
|
145
209
|
const desc = grabMeta(head, 'name', 'description');
|
|
146
210
|
if (!desc) fail(label, 'missing meta description');
|
|
147
|
-
else if (
|
|
148
|
-
|
|
211
|
+
else if (!noindex) {
|
|
212
|
+
const d = decodeEntities(desc).length;
|
|
213
|
+
if (d < 70 || d > 165) warn(label, 'description ' + d + ' chars (outside 70-165, may truncate)');
|
|
149
214
|
}
|
|
150
215
|
|
|
151
216
|
const canonical = grabCanonical(head);
|
|
@@ -161,12 +226,26 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
161
226
|
['duplicate meta description', /<meta[^>]+name=["']description["']/gi],
|
|
162
227
|
['multiple <title> elements', /<title[^>]*>/gi],
|
|
163
228
|
['duplicate og:title', /<meta[^>]+property=["']og:title["']/gi],
|
|
229
|
+
['duplicate og:description', /<meta[^>]+property=["']og:description["']/gi],
|
|
230
|
+
['duplicate twitter:title', /<meta[^>]+name=["']twitter:title["']/gi],
|
|
231
|
+
['duplicate twitter:card', /<meta[^>]+name=["']twitter:card["']/gi],
|
|
164
232
|
]) {
|
|
165
233
|
if (countHead(head, dup[1]) > 1) fail(label, dup[0]);
|
|
166
234
|
}
|
|
167
235
|
|
|
236
|
+
// og/twitter CORE class. FAIL only when the page carries NO tag of the
|
|
237
|
+
// set at all: that is the "the engine failed to inject on a bare page"
|
|
238
|
+
// case the class exists to catch. A page shipping a PARTIAL set of its
|
|
239
|
+
// own (an SSG theme emitting only og:title, say) makes the engine's
|
|
240
|
+
// group-level missing-only gate back off the whole group BY DESIGN, so
|
|
241
|
+
// its missing companions are report-grade - failing them punished the
|
|
242
|
+
// very contract (SSG-owned partial sets) the injection rule preserves,
|
|
243
|
+
// and the audit cannot tell an SSG-owned partial head from an engine one.
|
|
244
|
+
const ogAny = countHead(head, /<meta[^>]+property=["']og:[^"']*["']/gi);
|
|
168
245
|
for (const prop of OG_CORE) {
|
|
169
|
-
if (
|
|
246
|
+
if (grabMeta(head, 'property', prop)) continue;
|
|
247
|
+
if (ogAny === 0) fail(label, 'missing ' + prop);
|
|
248
|
+
else warn(label, prop + ' absent (page ships a partial og set; SSG-owned partial sets keep contract)');
|
|
170
249
|
}
|
|
171
250
|
for (const prop of OG_SET) {
|
|
172
251
|
if (!grabMeta(head, 'property', prop)) {
|
|
@@ -174,19 +253,26 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
174
253
|
}
|
|
175
254
|
}
|
|
176
255
|
if (!grabMeta(head, 'property', 'og:image')) {
|
|
177
|
-
|
|
256
|
+
configGate('og:image', label);
|
|
178
257
|
}
|
|
258
|
+
const twAny = countHead(head, /<meta[^>]+name=["']twitter:[^"']*["']/gi);
|
|
179
259
|
for (const name of TW_CORE) {
|
|
180
|
-
if (
|
|
260
|
+
if (grabMeta(head, 'name', name)) continue;
|
|
261
|
+
if (twAny === 0) fail(label, 'missing ' + name);
|
|
262
|
+
else warn(label, name + ' absent (page ships a partial twitter set; SSG-owned partial sets keep contract)');
|
|
181
263
|
}
|
|
182
264
|
for (const name of TW_SET) {
|
|
183
265
|
// Symmetry with the engine's own late pass (seo.js): without a
|
|
184
266
|
// configured handle the injector ships neither twitter:site nor
|
|
185
267
|
// twitter:creator, so the audit grades those absences only when the
|
|
186
268
|
// handle exists. The 'config-gated' wording in the warning now holds.
|
|
187
|
-
if ((name === 'twitter:site' || name === 'twitter:creator') && !config
|
|
269
|
+
if ((name === 'twitter:site' || name === 'twitter:creator') && !config?.twitter?.username) continue;
|
|
188
270
|
if (!grabMeta(head, 'name', name)) {
|
|
189
|
-
|
|
271
|
+
// twitter:image is config-gated (no og.image -> the engine ships no
|
|
272
|
+
// twitter:image either): collapse it like og:image. The rest of the
|
|
273
|
+
// twitter set is SSG-owned-partial-set shaped and stays per-page.
|
|
274
|
+
if (name === 'twitter:image') configGate('twitter:image', label);
|
|
275
|
+
else warn(label, name + ' absent (config-gated or SSG-owned partial set; add a twitter username / og.image as needed)');
|
|
190
276
|
}
|
|
191
277
|
}
|
|
192
278
|
|
|
@@ -197,16 +283,23 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
197
283
|
if (ogImage && !grabMeta(head, 'property', 'og:image:width')) {
|
|
198
284
|
warn(label, 'og:image:width/height/alt absent (the og.image object form supplies them)');
|
|
199
285
|
}
|
|
200
|
-
if (ogImage
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
286
|
+
if (ogImage) {
|
|
287
|
+
const info = sitePathInfo(ogImage, { outputDir, config });
|
|
288
|
+
if (info && !info.inside) {
|
|
289
|
+
// A site-local og:image that escapes the output tree can never be a
|
|
290
|
+
// served artifact; report it (same report-grade posture as missing).
|
|
291
|
+
warn(label, 'og:image points at ' + ogImage + ' which escapes the output directory');
|
|
292
|
+
} else if (info && !existsSync(info.resolved)) {
|
|
293
|
+
// Report-grade on purpose (issue #18 sketch: 'worth having'): an og
|
|
294
|
+
// image can legitimately be emitted on an SSG-side pass or a same-host
|
|
295
|
+
// CDN, so a hard FAIL would punish valid setups; the JSON digest
|
|
296
|
+
// carries it for teams who want to harden it in CI.
|
|
297
|
+
warn(label, 'og:image points at ' + ogImage + ' which does not exist on disk');
|
|
298
|
+
}
|
|
206
299
|
}
|
|
207
300
|
|
|
208
301
|
const ldBlocks = [...html.matchAll(/<script type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
|
|
209
|
-
if (!ldBlocks.length) warn(label, 'no JSON-LD structured data');
|
|
302
|
+
if (!ldBlocks.length && !noindex) warn(label, 'no JSON-LD structured data');
|
|
210
303
|
for (const block of ldBlocks) {
|
|
211
304
|
let parsed;
|
|
212
305
|
try { parsed = JSON.parse(block[1]); } catch (e) {
|
|
@@ -225,8 +318,10 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
225
318
|
// schema-carried logo/image files: same exists-on-disk check as og:image
|
|
226
319
|
for (const key of ['logo', 'image']) {
|
|
227
320
|
const v = node[key];
|
|
228
|
-
const
|
|
229
|
-
if (
|
|
321
|
+
const info = sitePathInfo(typeof v === 'string' ? v : null, { outputDir, config });
|
|
322
|
+
if (info && !info.inside) {
|
|
323
|
+
warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which escapes the output directory');
|
|
324
|
+
} else if (info && !existsSync(info.resolved)) {
|
|
230
325
|
warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which does not exist on disk');
|
|
231
326
|
}
|
|
232
327
|
}
|
|
@@ -235,15 +330,23 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
235
330
|
|
|
236
331
|
const h1s = [...html.matchAll(/<h1[^>]*>([\s\S]*?)<\/h1>/gi)];
|
|
237
332
|
const h1Texts = h1s.map(m => stripTags(m[1])).filter(Boolean);
|
|
238
|
-
if (!h1Texts.length) warn(label, 'no non-empty <h1>');
|
|
333
|
+
if (!h1Texts.length && !noindex) warn(label, 'no non-empty <h1>');
|
|
239
334
|
else if (h1Texts.length > 1) warn(label, h1Texts.length + ' <h1> elements (prefer one)');
|
|
240
335
|
}
|
|
241
336
|
|
|
242
337
|
// ---------- site-level artifacts ----------
|
|
243
338
|
|
|
339
|
+
// Honor the config's own generate switches: with `generateSitemap: false`
|
|
340
|
+
// (or robots), the run never writes the artifact, so its absence is the
|
|
341
|
+
// documented behavior, not a failure. The audit previously FAILed the
|
|
342
|
+
// missing file the config explicitly asked not to produce, so a
|
|
343
|
+
// legitimately-configured site could never pass its own audit. Configless
|
|
344
|
+
// audits (config === null) keep the strict check - absence is then a real
|
|
345
|
+
// gap. A DISABLED artifact that is present is still checked for shape.
|
|
346
|
+
const sitemapDisabled = config?.generateSitemap === false;
|
|
244
347
|
const sitemapPath = path.join(outputDir, 'sitemap.xml');
|
|
245
348
|
if (!existsSync(sitemapPath)) {
|
|
246
|
-
fail('sitemap.xml', 'missing');
|
|
349
|
+
if (!sitemapDisabled) fail('sitemap.xml', 'missing');
|
|
247
350
|
} else {
|
|
248
351
|
const s = readFileSync(sitemapPath, 'utf8');
|
|
249
352
|
sitemapLocs = (s.match(/<loc>/g) || []).length;
|
|
@@ -251,9 +354,10 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
251
354
|
if (!s.includes('<lastmod>')) warn('sitemap.xml', 'no <lastmod> dates (engine runs always add them; hand-stripped?)');
|
|
252
355
|
}
|
|
253
356
|
|
|
357
|
+
const robotsDisabled = config?.generateRobots === false;
|
|
254
358
|
const robotsPath = path.join(outputDir, 'robots.txt');
|
|
255
359
|
if (!existsSync(robotsPath)) {
|
|
256
|
-
fail('robots.txt', 'missing');
|
|
360
|
+
if (!robotsDisabled) fail('robots.txt', 'missing');
|
|
257
361
|
} else {
|
|
258
362
|
const r = readFileSync(robotsPath, 'utf8');
|
|
259
363
|
if (!/Sitemap:/i.test(r)) fail('robots.txt', 'no Sitemap: pointer');
|
|
@@ -261,6 +365,34 @@ export function auditOutput({ outputDir, config, json = false, capture = false }
|
|
|
261
365
|
|
|
262
366
|
// ---------- report ----------
|
|
263
367
|
|
|
368
|
+
// Flush config-gated absences as ONE line each (issue #28), with the
|
|
369
|
+
// affected-page count. Deterministic order (og:image before twitter:image).
|
|
370
|
+
// The og:image wording stays truthful when the config DOES supply an image
|
|
371
|
+
// but not for every page (og.scoped, ogImageGeneration): the line then
|
|
372
|
+
// reads "absent on N page(s)" rather than the false "not configured".
|
|
373
|
+
const ogScopedCfg = config?.og?.scoped;
|
|
374
|
+
const ogImageConfigured = Boolean(config?.og?.image)
|
|
375
|
+
|| Boolean(config?.ogImageGeneration)
|
|
376
|
+
|| Boolean(ogScopedCfg && typeof ogScopedCfg === 'object' && !Array.isArray(ogScopedCfg)
|
|
377
|
+
&& Object.keys(ogScopedCfg).length > 0);
|
|
378
|
+
for (const [key, pages] of [...configGated.entries()].sort()) {
|
|
379
|
+
const n = pages.size;
|
|
380
|
+
let msg;
|
|
381
|
+
if (key === 'og:image') {
|
|
382
|
+
msg = ogImageConfigured
|
|
383
|
+
? 'og:image absent on ' + n + ' page(s) - no og.image covers them (add one, or extend og.scoped)'
|
|
384
|
+
: 'og:image not configured - ' + n + ' page(s) affected (add one for share previews)';
|
|
385
|
+
} else {
|
|
386
|
+
// twitter:image ships alongside og:image (the injector derives it), so
|
|
387
|
+
// its absence tracks the same config state: "not configured" only when
|
|
388
|
+
// NO og image source exists at all, else "absent on N page(s)".
|
|
389
|
+
msg = ogImageConfigured
|
|
390
|
+
? key + ' absent on ' + n + ' page(s) - no og.image covers them (add one, or extend og.scoped)'
|
|
391
|
+
: key + ' not configured - ' + n + ' page(s) affected (add a twitter username / og.image as needed)';
|
|
392
|
+
}
|
|
393
|
+
warn('config', msg);
|
|
394
|
+
}
|
|
395
|
+
|
|
264
396
|
if (capture) {
|
|
265
397
|
return { fails, warns, pages: pages.length, sitemapLocs };
|
|
266
398
|
}
|
package/engine/config.js
CHANGED
|
@@ -20,6 +20,7 @@ export const KNOWN_CONFIG_KEYS = Object.freeze([
|
|
|
20
20
|
'site', 'twitter', 'og', 'author', 'jsonLd', 'basePath', 'outputDir', 'minify', 'runHooks',
|
|
21
21
|
'generateSitemap', 'generateFeed', 'generate404', 'generateRobots',
|
|
22
22
|
'readingTime', 'lastModified', 'lazyLoad', 'linkCheck', 'searchIndex',
|
|
23
|
+
'tableWrap',
|
|
23
24
|
'cacheBusting', 'hooks', 'indexNow', 'ogImageGeneration',
|
|
24
25
|
'search', 'breadcrumb', 'verification', 'llms', 'locales', 'article',
|
|
25
26
|
]);
|
package/engine/hooks.js
CHANGED
|
@@ -42,14 +42,20 @@ export function runPurgeHooks(config, buildHash, outputDir) {
|
|
|
42
42
|
|
|
43
43
|
command = substitutePlaceholders(command, { siteUrl, buildHash, outputDir });
|
|
44
44
|
|
|
45
|
+
if (!config.runHooks) {
|
|
46
|
+
// Report EVERY configured hook (not just the first): the old `return`
|
|
47
|
+
// sat inside the loop, so a multi-hook config printed one line and
|
|
48
|
+
// silently hid the rest, leaving the operator unsure what a later
|
|
49
|
+
// runHooks: true would actually execute.
|
|
50
|
+
console.log('Purge hook ' + name + ': running...');
|
|
51
|
+
console.log(' Command: ' + command.substring(0, 100) + '...');
|
|
52
|
+
console.log(' Purge hooks disabled (set runHooks: true to enable)');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
console.log('Purge hook ' + name + ': running...');
|
|
46
57
|
try {
|
|
47
|
-
|
|
48
|
-
console.log(' Command: ' + command.substring(0, 100) + '...');
|
|
49
|
-
console.log(' Purge hooks disabled (set runHooks: true to enable)');
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
execSync(command, { stdio: 'inherit', timeout: 30000 });
|
|
58
|
+
execSync(command, { stdio: 'inherit', timeout: 30000, env: hookEnv({ siteUrl, buildHash, outputDir }) });
|
|
53
59
|
console.log(' Command: ' + command.substring(0, 100) + '...');
|
|
54
60
|
console.log('Purge hook ' + name + ': done');
|
|
55
61
|
} catch (e) {
|
|
@@ -85,9 +91,11 @@ export function runPostBuildHooks(config, buildHash, outputDir) {
|
|
|
85
91
|
const command = substitutePlaceholders(entry, { siteUrl, buildHash, outputDir });
|
|
86
92
|
|
|
87
93
|
if (!config.runHooks) {
|
|
94
|
+
// Report EVERY configured hook (mirror of the purge loop fix): the old
|
|
95
|
+
// `return` inside the loop hid every entry after the first.
|
|
88
96
|
console.log('PostBuild hook: Command: ' + command.substring(0, 100) + '...');
|
|
89
97
|
console.log('PostBuild hooks disabled (set runHooks: true to enable)');
|
|
90
|
-
|
|
98
|
+
continue;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
console.log('PostBuild hook: running ' + command.substring(0, 76) + '...');
|
package/engine/indexnow.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import crypto from 'crypto';
|
|
13
13
|
import path from 'path';
|
|
14
|
+
import { decodeEntities } from './scan.js';
|
|
15
|
+
import { pageUrlFor } from './urls.js';
|
|
14
16
|
|
|
15
17
|
export const INDEXNOW_API = 'https://api.indexnow.org/indexnow';
|
|
16
18
|
const KEYFILE_HIDDEN = ['indexnow-key.txt'];
|
|
@@ -69,8 +71,11 @@ export function readSitemapUrls(outputDir) {
|
|
|
69
71
|
const sitemapPath = path.join(outputDir, 'sitemap.xml');
|
|
70
72
|
if (!fs.existsSync(sitemapPath)) return null;
|
|
71
73
|
const sitemap = fs.readFileSync(sitemapPath, 'utf8');
|
|
74
|
+
// The sitemap's <loc> values are XML-escaped by the writer (& -> &), so
|
|
75
|
+
// they must decode back to the real URL before submission - posting the
|
|
76
|
+
// escaped form asks the engines to crawl a URL that does not exist.
|
|
72
77
|
const urls = (sitemap.match(/<loc>([\s\S]*?)<\/loc>/g) || [])
|
|
73
|
-
.map(e => e.replace(/<\/?loc>/g, '').trim())
|
|
78
|
+
.map(e => decodeEntities(e.replace(/<\/?loc>/g, '').trim()))
|
|
74
79
|
.filter(Boolean);
|
|
75
80
|
return urls.length > 0 ? urls : null;
|
|
76
81
|
}
|
|
@@ -107,7 +112,11 @@ export async function submitIndexNow({ config, configDir, outputDir, dryRun }) {
|
|
|
107
112
|
host,
|
|
108
113
|
key: resolved.key,
|
|
109
114
|
urlList: urls,
|
|
110
|
-
|
|
115
|
+
// The key file is emitted into the OUTPUT ROOT, which a sub-path install
|
|
116
|
+
// serves under basePath (same contract as sitemap.xml/robots.txt). A raw
|
|
117
|
+
// site.url + '/' + key.txt pointed at the domain root, so the engines
|
|
118
|
+
// probed a URL that 404s and refused the submission (the #3 bug class).
|
|
119
|
+
keyLocation: pageUrlFor(config, resolved.key + '.txt'),
|
|
111
120
|
};
|
|
112
121
|
|
|
113
122
|
console.log('[freestruct] indexnow: submitting ' + urls.length + ' URL(s) to ' + INDEXNOW_API
|