freestruct 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/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
- const keyRe = new RegExp(`<meta[^>]${attrHasKey}=["']${name}["'][^>]*>`, 'i');
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].match(/content=["']([^"']*)["']/i);
44
- return content ? content[1] : null;
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=["']([^"']+)["'][^>]+rel=["']canonical["'][^>]*>/i);
69
+ || head.match(/<link[^>]+href=["'][^"']+["'][^>]+rel=["']canonical["'][^>]*>/i);
50
70
  if (!link) return null;
51
- const href = link[0].match(/href=["']([^"']+)["']/i);
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, '').replace(/&amp;/g, '&').trim();
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 ('&amp;', '&#39;',
95
+ // '&#65;') 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
- /** Head-referenced URL -> on-disk path under the output dir, or null. */
75
- function urlToDisk(urlValue, { outputDir, config }) {
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
- if (config?.basePath && pathName.startsWith(config.basePath)) {
91
- pathName = pathName.slice(config.basePath.length);
92
- }
93
- return path.join(outputDir, pathName.replace(/^\//, ''));
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
- return null;
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 = stripTags(titleMatch[1]).length;
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 (desc.length < 70 || desc.length > 165) {
148
- warn(label, 'description ' + desc.length + ' chars (outside 70-165, may truncate)');
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 (!grabMeta(head, 'property', prop)) fail(label, 'missing ' + prop);
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
- warn(label, 'og:image absent (no og.image configured? add one for share previews)');
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 (!grabMeta(head, 'name', name)) fail(label, 'missing ' + name);
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.twitter?.username) continue;
269
+ if ((name === 'twitter:site' || name === 'twitter:creator') && !config?.twitter?.username) continue;
188
270
  if (!grabMeta(head, 'name', name)) {
189
- warn(label, name + ' absent (config-gated or SSG-owned partial set; add a twitter username / og.image as needed)');
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 && urlToDisk(ogImage, { outputDir, config }) && !existsSync(urlToDisk(ogImage, { outputDir, config }))) {
201
- // Report-grade on purpose (issue #18 sketch: 'worth having'): an og
202
- // image can legitimately be emitted on an SSG-side pass or a same-host
203
- // CDN, so a hard FAIL would punish valid setups; the JSON digest
204
- // carries it for teams who want to harden it in CI.
205
- warn(label, 'og:image points at ' + ogImage + ' which does not exist on disk');
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 target = urlToDisk(typeof v === 'string' ? v : null, { outputDir, config });
229
- if (target && !existsSync(target)) {
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
- if (!config.runHooks) {
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
- return [];
98
+ continue;
91
99
  }
92
100
 
93
101
  console.log('PostBuild hook: running ' + command.substring(0, 76) + '...');
@@ -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 (& -> &amp;), 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
- keyLocation: config.site.url.replace(/\/$/, '') + '/' + resolved.key + '.txt',
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