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/inject.js CHANGED
@@ -35,8 +35,8 @@ import path from 'path';
35
35
  import { pathToFileURL, fileURLToPath } from 'url';
36
36
 
37
37
  import { loadConfig } from './config.js';
38
- import { getHtmlFiles, buildPages, computeBuildHash, htmlIsNoindex, hasSeoType } from './scan.js';
39
- import { injectPage, addReadingTime, addLastModified, addLazyLoading } from './seo.js';
38
+ import { getHtmlFiles, buildPages, computeBuildHash, htmlIsNoindex, hasSeoType, dedupeBySlug } from './scan.js';
39
+ import { injectPage, addReadingTime, addLastModified, addLazyLoading, addTableWrap } from './seo.js';
40
40
  import { generateSitemap, generateRobots, generateFeed } from './xmllike.js';
41
41
  import { generate404, pipeline404 } from './notfound.js';
42
42
  import { checkLinks } from './linkcheck.js';
@@ -55,14 +55,35 @@ const _flags = _argv.filter(a => FLAG_VALUES.has(a));
55
55
  // any other bare word stays the outputDir override it always was.
56
56
  const COMMANDS = new Set(['validate', 'init', 'help', 'audit', 'indexnow']);
57
57
  const _firstArg = _argv.find(a => !a.startsWith('--'));
58
- const _positional = _firstArg && !COMMANDS.has(_firstArg) ? _firstArg : undefined;
59
- // Pass 26.16: dash-shaped args are FLAGS, never output dirs. The audit and
60
- // indexnow subcommands validate their own flags; the default run must do the
61
- // same for its set, instead of letting a typo'd flag ride as a positional
62
- // and inject somewhere the user never meant (the same quiet-mistake class
63
- // the source-tree guard closes).
64
- const KNOWN_DEFAULT_DASHES = ['-h', '--help', '--version', '--strict', '--tolerant'];
65
- const unknownDashArgs = _argv.filter(a => a.startsWith('-') && !KNOWN_DEFAULT_DASHES.includes(a));
58
+ // Args that follow a subcommand word (`_argv` minus the command itself).
59
+ const afterCommand = (name) => _argv.slice(_argv.indexOf(name) + 1);
60
+
61
+ // Pass 26.16 + 26.44: dash-shaped args are FLAGS, never output dirs, and each
62
+ // command's flag/positional surface is CLOSED. An unknown flag or a surplus
63
+ // positional is a typo, refused with the supported set instead of silently
64
+ // ignored - before this, `init --bogus` scaffolded into a directory literally
65
+ // named `--bogus`, `init a b c` dropped `b`/`c`, `audit -x` rode as an output
66
+ // dir (exit 3, "not found") and `validate --bogus` ran as if clean.
67
+ const GLOBAL_FLAGS = ['-h', '--help', '--version', '--strict', '--tolerant'];
68
+ function parseArgs(args, { command, flags = [], maxPositionals = 0 }) {
69
+ const allowed = new Set([...GLOBAL_FLAGS, ...flags]);
70
+ const seen = new Set();
71
+ const positionals = [];
72
+ for (const a of args) {
73
+ if (a.startsWith('-')) {
74
+ if (!allowed.has(a)) {
75
+ fail(command + ': unknown flag ' + a + ' - run freestruct --help for the flag list (supported: ' + (flags.length ? flags.join(' ') : 'none') + ')');
76
+ }
77
+ seen.add(a);
78
+ } else {
79
+ positionals.push(a);
80
+ }
81
+ }
82
+ if (positionals.length > maxPositionals) {
83
+ fail(command + ': too many arguments (got ' + positionals.length + ', expected at most ' + maxPositionals + ')');
84
+ }
85
+ return { positionals, flags: seen };
86
+ }
66
87
 
67
88
  export const EXIT_OK = 0;
68
89
  export const EXIT_CONFIG = 1;
@@ -128,6 +149,7 @@ function printHelp() {
128
149
  * exits EXIT_CONFIG, honoring --tolerant like every other fatal.
129
150
  */
130
151
  function cmdValidate() {
152
+ parseArgs(afterCommand('validate'), { command: 'validate' });
131
153
  let loaded;
132
154
  try {
133
155
  loaded = loadConfig(process.cwd());
@@ -144,7 +166,9 @@ function cmdValidate() {
144
166
  * clobbering an existing file. Mirrors the manual `cp -r node_modules/
145
167
  * freestruct/init/docs .` shape the init kit README describes.
146
168
  */
147
- function cmdInit(dirArg) {
169
+ function cmdInit() {
170
+ const { positionals } = parseArgs(afterCommand('init'), { command: 'init', maxPositionals: 1 });
171
+ const dirArg = positionals[0];
148
172
  const targetRoot = path.resolve(process.cwd(), dirArg || '.');
149
173
  const docsDir = path.join(targetRoot, 'docs');
150
174
  // engine/inject.js -> package root -> init/docs (files list ships init/)
@@ -178,11 +202,12 @@ export function runInject(options = {}) {
178
202
  // them (with --external the only path that lives past the import, this
179
203
  // guard is what keeps the engine from re-injecting mid-audit).
180
204
  if (_firstArg && COMMANDS.has(_firstArg)) return;
181
- // Unknown dash-words are typos, not directories: fail with the usage hint
182
- // instead of riding as a positional (Pass 26.16).
183
- if (unknownDashArgs.length) {
184
- fail('unknown flag(s): ' + unknownDashArgs.join(' ') + ' - run freestruct --help for the flag list', EXIT_CONFIG);
185
- }
205
+ // The default run's own closed surface (Pass 26.44): flags are the three
206
+ // run-shaping ones, and at most ONE positional (the output-dir override).
207
+ // An unknown flag or a surplus positional is refused with the usage hint
208
+ // instead of riding as a directory (Pass 26.16's typo class, closed).
209
+ const { positionals: defaultPositionals } = parseArgs(_argv, { command: 'freestruct', flags: ['--strict', '--tolerant'], maxPositionals: 1 });
210
+ const _positional = defaultPositionals[0];
186
211
  const cwd = options.cwd || process.cwd();
187
212
  console.log('freestruct: Loading config...');
188
213
 
@@ -239,6 +264,22 @@ export function runInject(options = {}) {
239
264
  if (!fs.existsSync(templatePath)) {
240
265
  fail('Template not found: ' + templatePath + ' (shipped copy also missing at ' + packageTemplateDir + ')', EXIT_TEMPLATE);
241
266
  }
267
+ // Template-shape warning (issue #46): a STAGED inject-brand.html REPLACES
268
+ // the packaged default outright (it is the whole head template, not a
269
+ // per-slot merge), so a partial file silently drops every slot it does not
270
+ // list - the packaged default carries canonical + the full og/twitter set +
271
+ // identity JSON-LD, and a host that stages "just the layer the layout lacks"
272
+ // loses the rest on EVERY page. Name the core slots a staged template omits.
273
+ const stagedTemplatePath = path.join(configDir, '_freestruct', 'inject-brand.html');
274
+ if (fs.existsSync(stagedTemplatePath)) {
275
+ const stagedTemplate = fs.readFileSync(stagedTemplatePath, 'utf8');
276
+ const CORE_SLOTS = ['{{canonicalUrl}}', '{{pageTitle}}'];
277
+ const missingSlots = CORE_SLOTS.filter(k => !stagedTemplate.includes(k));
278
+ if (missingSlots.length) {
279
+ console.log('[freestruct] config warning: staged docs/_freestruct/inject-brand.html REPLACES the packaged head template (it is not merged per slot) and omits '
280
+ + missingSlots.join(', ') + ' - those head slots ship missing on every page. Carry the full default set, or delete the file to use the packaged template.');
281
+ }
282
+ }
242
283
  const template = fs.readFileSync(templatePath, 'utf8');
243
284
 
244
285
  const htmlFiles = getHtmlFiles(outputDir);
@@ -282,14 +323,18 @@ export function runInject(options = {}) {
282
323
 
283
324
  // 404 pipeline: generated when absent, user-supplied folded into the
284
325
  // same full treatment (Phase 1.4). Runs after regular pages so a 404
285
- // already present in the tree is not double-processed above.
326
+ // already present in the tree is not double-processed above. EVERY 404
327
+ // file gets the treatment: a tree carrying both 404.html and 404.htm (a
328
+ // valid pair on hosts that serve either) had only the FIRST processed, so
329
+ // the second shipped with no noindex meta and the audit failed the run's
330
+ // own output.
286
331
  const processed404s = [];
287
332
  if (config.generate404 !== false) {
288
- let page404 = pages.find(p => p.is404);
289
- if (!page404) {
333
+ const found404s = pages.filter(p => p.is404);
334
+ if (found404s.length === 0) {
290
335
  const file404 = generate404(config, outputDir, buildHash, template, snippetDir);
291
336
  const html404 = fs.readFileSync(file404, 'utf8');
292
- page404 = {
337
+ found404s.push({
293
338
  file: file404,
294
339
  rel: '404.html',
295
340
  slug: '/404',
@@ -298,9 +343,9 @@ export function runInject(options = {}) {
298
343
  mtime: new Date(),
299
344
  title: html404.match(/<title>([^<]+)<\/title>/i)?.[1]?.split(' | ')[0] || null,
300
345
  description: html404.match(/<meta name="description" content="([^"]+)"/i)?.[1] || null,
301
- };
346
+ });
302
347
  }
303
- processed404s.push(pipeline404(page404, injectCtx));
348
+ for (const page404 of found404s) processed404s.push(pipeline404(page404, injectCtx));
304
349
  }
305
350
 
306
351
  // Late passes over every processed page, 404s included (Phase 1.4)
@@ -308,17 +353,42 @@ export function runInject(options = {}) {
308
353
  if (config.readingTime !== false) for (const page of latePages) addReadingTime(page.file);
309
354
  if (config.lastModified !== false) for (const page of latePages) addLastModified(page.file);
310
355
  if (config.lazyLoad !== false) for (const page of latePages) addLazyLoading(page.file);
356
+ // table guard (issue #35): wrap wide tables so every downstream site is
357
+ // mobile-safe by construction. Default-on; `tableWrap: false` opts out.
358
+ if (config.tableWrap !== false) for (const page of latePages) addTableWrap(page.file);
359
+
360
+ // Restore each page's pre-injection mtime after our writes. freestruct
361
+ // rewrites every page it decorates, so without this the NEXT run's
362
+ // buildPages snapshot captures freestruct's own write as the page mtime -
363
+ // and feed pubDate / sitemap lastmod (both derived from that mtime, the
364
+ // issue #9 honest-date contract: "the date the page last actually
365
+ // changed, not the build time") churned on every rerun with no content
366
+ // change. Restoring makes the dates stable across reruns and still
367
+ // advances when the SSG rebuilds the page. The generated 404 is excluded
368
+ // (freestruct owns it; it never reaches the feed/sitemap).
369
+ for (const page of latePages) {
370
+ try { fs.utimesSync(page.file, page.mtime, page.mtime); } catch (e) { /* mtime best-effort */ }
371
+ }
311
372
 
312
373
  // noindex is honored by default (owner decision, issue #5):
313
374
  // noindex pages never ship into sitemap/feed/search
314
- const indexablePages = pages.filter(p => !p.is404 && !htmlIsNoindex(p.html));
375
+ // Dedupe by slug first: a tree with both `a.html` and `a.htm` (or
376
+ // `index.html`/`index.htm` in one dir) maps two files onto ONE url, and
377
+ // without this the artifact lists that url twice (duplicate <loc>/<item>).
378
+ // Both files still get decorated above; only the url listings collapse.
379
+ const indexablePages = dedupeBySlug(pages.filter(p => !p.is404 && !htmlIsNoindex(p.html)));
315
380
 
316
381
  if (config.generateSitemap !== false) generateSitemap(indexablePages, config, outputDir);
317
382
  if (config.generateRobots !== false) generateRobots(config, outputDir);
318
383
  if (config.generateFeed !== false) generateFeed(indexablePages, config, outputDir);
319
384
 
320
- // link check over regular pages (404 targets excluded by design)
321
- const brokenLinks = config.linkCheck !== false ? checkLinks(sitePages.map(p => p.file), outputDir) : [];
385
+ // link check over regular pages (404 targets excluded by design).
386
+ // basePath threads through so root-absolute hrefs on a baseurl'd site
387
+ // (which carry the deploy prefix) resolve against the tree, not the
388
+ // prefix as a directory (issue #32).
389
+ const brokenLinks = config.linkCheck !== false
390
+ ? checkLinks(sitePages.map(p => p.file), outputDir, config.basePath)
391
+ : [];
322
392
 
323
393
  // opt-in search index (owner decision: search.json is opt-in as of Phase 2)
324
394
  if (config.searchIndex === true) generateSearchIndex(indexablePages, config, outputDir);
@@ -378,8 +448,12 @@ export function runInject(options = {}) {
378
448
  // (the pre-change --version module-level check had the same shape). Library
379
449
  // importers pass no such argv and simply skip all of these.
380
450
  if (_flags.includes('--version')) { printVersion(); process.exit(0); }
381
- // `help` is the subcommand; --help is the flag alias (both print the same text)
382
- if (_firstArg === 'help' || _argv.includes('--help')) { printHelp(); process.exit(EXIT_OK); }
451
+ // `help` is the subcommand; --help and -h are the flag aliases (all print the
452
+ // same text). -h must be handled HERE: it is listed in KNOWN_DEFAULT_DASHES,
453
+ // so without this branch it passes the unknown-flag guard and then rides as
454
+ // the positional output dir (the run then dies with "Output directory not
455
+ // found: .../-h").
456
+ if (_firstArg === 'help' || _argv.includes('--help') || _argv.includes('-h')) { printHelp(); process.exit(EXIT_OK); }
383
457
  // ---------- audit subcommand (issue #18) ----------
384
458
 
385
459
  // `freestruct audit [outputDir] [--json]`: verify the BAKED output (heads +
@@ -389,14 +463,12 @@ if (_firstArg === 'help' || _argv.includes('--help')) { printHelp(); process.exi
389
463
  // the run walks up from cwd (it maps basePath for the on-disk asset checks);
390
464
  // a configless audit just runs without the disk-mapping extras.
391
465
  async function cmdAudit() {
392
- const auditArgs = _argv.slice(_argv.indexOf('audit') + 1);
393
- const useJson = auditArgs.includes('--json');
394
- const useExternal = auditArgs.includes('--external');
395
- const explicitDir = auditArgs.find(a => !a.startsWith('--'));
396
-
397
- if (auditArgs.filter(a => a.startsWith('--')).some(a => a !== '--json' && a !== '--external')) {
398
- fail('audit: only --json and --external are supported flags (got ' + auditArgs.filter(a => a.startsWith('--')).join(' ') + ')');
399
- }
466
+ const { positionals, flags } = parseArgs(afterCommand('audit'), {
467
+ command: 'audit', flags: ['--json', '--external'], maxPositionals: 1,
468
+ });
469
+ const useJson = flags.has('--json');
470
+ const useExternal = flags.has('--external');
471
+ const explicitDir = positionals[0];
400
472
 
401
473
  let config = null;
402
474
  let configDir = process.cwd();
@@ -443,13 +515,11 @@ async function cmdAudit() {
443
515
  // verify ownership, so this is documented as the post-deploy step (CI often
444
516
  // wants --dry-run at build time). Exit 0 accepted/dry-run | 1 failure.
445
517
  async function cmdIndexNow() {
446
- const idxArgs = _argv.slice(_argv.indexOf('indexnow') + 1);
447
- const dryRun = idxArgs.includes('--dry-run');
448
- const explicitDir = idxArgs.find(a => !a.startsWith('--'));
449
-
450
- if (idxArgs.filter(a => a.startsWith('--')).some(a => a !== '--dry-run')) {
451
- fail('indexnow: only --dry-run is a supported flag');
452
- }
518
+ const { positionals, flags } = parseArgs(afterCommand('indexnow'), {
519
+ command: 'indexnow', flags: ['--dry-run'], maxPositionals: 1,
520
+ });
521
+ const dryRun = flags.has('--dry-run');
522
+ const explicitDir = positionals[0];
453
523
 
454
524
  let config = null;
455
525
  let configDir = process.cwd();
@@ -482,7 +552,7 @@ async function cmdIndexNow() {
482
552
  }
483
553
 
484
554
  if (_firstArg === 'validate') cmdValidate();
485
- if (_firstArg === 'init') cmdInit(_argv[_argv.indexOf('init') + 1]);
555
+ if (_firstArg === 'init') cmdInit();
486
556
  if (_firstArg === 'audit') cmdAudit().catch((e) => fail('audit: ' + (e && e.message ? e.message : e)));
487
557
  if (_firstArg === 'indexnow') { cmdIndexNow(); }
488
558
 
@@ -15,9 +15,21 @@ import path from 'path';
15
15
  * then the .html/.htm sibling file. Assets stay out of the checker's
16
16
  * purview (see the shape test above the flags).
17
17
  */
18
- export function checkLinks(files, outputDir) {
18
+ export function checkLinks(files, outputDir, basePath = '') {
19
19
  const broken = [];
20
20
 
21
+ // basePath is the URL path prefix the output tree is SERVED under (same
22
+ // semantics urls.js owns for canonical/sitemap). A root-absolute href on a
23
+ // baseurl'd site carries that prefix ("/myrepo/guides/cli"), but inside the
24
+ // output tree the prefix is not a directory - resolving it literally turned
25
+ // every nav link into a miss and failed --strict for every baseurl'd site
26
+ // (issue #32). Strip the EXACT prefix before resolving; an href carrying a
27
+ // DIFFERENT prefix stays broken (the strip must match the configured
28
+ // basePath, not "any leading segment").
29
+ const prefix = basePath
30
+ ? ('/' + String(basePath).replace(/^\/+/, '')).replace(/\/+$/, '')
31
+ : '';
32
+
21
33
  for (const file of files) {
22
34
  // Payload stripping (Pass 26.14): inline JS template strings
23
35
  // ("<a href=" + url + ">" shapes) are NOT links; rouge-fenced code
@@ -31,10 +43,16 @@ export function checkLinks(files, outputDir) {
31
43
  .replace(/<code[\s\S]*?<\/code>/gi, '');
32
44
  const baseDir = path.dirname(file);
33
45
 
34
- const linkRegex = /href="([^"#]+)(?:#[^"]*)?"/g;
46
+ // Both quote styles (bug fix): the old /href="..."/ only saw
47
+ // double-quoted hrefs, so a single-quoted broken link (`href='/gone/'`,
48
+ // valid HTML) sailed through --strict. The backreference keeps the pair
49
+ // consistent; the fragment is stripped from the checked target either way.
50
+ // Case-insensitive (HTML attribute names are): an upper-case `HREF=`
51
+ // (valid markup) was skipped entirely, so a broken link shipped green.
52
+ const linkRegex = /href=(["'])([^"'#]+)(?:#[^"']*)?\1/gi;
35
53
  let match;
36
54
  while ((match = linkRegex.exec(html)) !== null) {
37
- const link = match[1];
55
+ const link = match[2];
38
56
 
39
57
  // Skip external, absolute-protocol, and ANY scheme-shaped target
40
58
  // (data:, javascript:, tel:, mailto:, ...): schemes are not routes.
@@ -44,9 +62,23 @@ export function checkLinks(files, outputDir) {
44
62
  // against the OUTPUT ROOT - path.join(baseDir, '/x') APPENDS, it never
45
63
  // resets, which turned every menu link on a nested page into a miss
46
64
  // (the CI --strict run caught it). Relative hrefs stay page-relative.
47
- const absPath = link.startsWith('/')
48
- ? path.join(outputDir, link)
49
- : path.join(baseDir, link);
65
+ // A root-absolute href that carries the configured basePath has that
66
+ // prefix stripped first (issue #32); one with a different prefix keeps
67
+ // it and resolves (correctly) as broken.
68
+ let target = link;
69
+ // A query string is not part of the route: '/guide?foo=bar' serves the
70
+ // same page as '/guide'. Resolving the raw value looked for a file
71
+ // literally named 'guide?foo=bar' and reported a valid link broken
72
+ // (failing --strict on any site whose nav carries query params, e.g. a
73
+ // utm-tagged or search-fed link). Strip it before resolving.
74
+ const q = target.indexOf('?');
75
+ if (q !== -1) target = target.slice(0, q);
76
+ if (target.startsWith('/') && prefix && (target === prefix || target.startsWith(prefix + '/'))) {
77
+ target = target.slice(prefix.length) || '/';
78
+ }
79
+ const absPath = target.startsWith('/')
80
+ ? path.join(outputDir, target)
81
+ : path.join(baseDir, target);
50
82
  const normalized = path.normalize(absPath);
51
83
 
52
84
  // Targeted file link: extension decides the check shape (case-
package/engine/llms.js CHANGED
@@ -23,6 +23,17 @@ function oneline(value) {
23
23
  .trim();
24
24
  }
25
25
 
26
+ /**
27
+ * A markdown link LABEL: `[` and `]` inside it would close the label early,
28
+ * so the emitted link renders as broken text (a title like 'Foo] bar' or a
29
+ * '[preview]' tag produced '- [Foo] bar](url)' - the URL spilled into the
30
+ * prose and the link broke). Escape the brackets, and the backslash itself,
31
+ * so the label always renders as the literal title.
32
+ */
33
+ function mdLabel(value) {
34
+ return oneline(value).replace(/([\\\[\]])/g, '\\$1');
35
+ }
36
+
26
37
  /**
27
38
  * Write llms.txt into the output dir. Pages only: indexable = non-404,
28
39
  * non-noindex (the same gate sitemap/feed honor, so noindex stays honored
@@ -41,7 +52,7 @@ export function generateLlms(indexablePages, config, outputDir) {
41
52
  lines.push('', '## Docs', '');
42
53
  for (const page of indexablePages) {
43
54
  const url = pageUrlFor(config, page.slug);
44
- const title = oneline(page.title || page.slug);
55
+ const title = mdLabel(page.title || page.slug);
45
56
  const desc = page.description ? ': ' + oneline(page.description) : '';
46
57
  lines.push('- [' + title + '](' + url + ')' + desc);
47
58
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
- import { injectPage } from './seo.js';
8
+ import { injectPage, escAttr } from './seo.js';
9
9
  import { pageUrlFor } from './urls.js';
10
10
  import { stripRobotsMeta } from './scan.js';
11
11
 
@@ -14,8 +14,12 @@ export function generate404(config, outputDir, buildHash, template, snippetDir)
14
14
  const file = path.join(outputDir, '404.html');
15
15
  // Links follow the pageUrlFor contract (basePath-aware) - raw site.url
16
16
  // guesses like '/docs/' point at 404s on sub-path installs (#3 class).
17
- const siteName = config.site.name || 'Site';
18
- const homeUrl = pageUrlFor(config, '/');
17
+ // The site name and the home URL land in HTML text/attribute slots, so they
18
+ // ride escAttr: a quote-bearing or '<'-bearing site name otherwise broke the
19
+ // <title> and could inject markup into the error page (the Pass 26.21 walk
20
+ // claimed this was escaped - it was not).
21
+ const siteName = escAttr(config.site.name || 'Site');
22
+ const homeUrl = escAttr(pageUrlFor(config, '/'));
19
23
  const notFound = `<!DOCTYPE html>
20
24
  <html lang="en">
21
25
  <head>
@@ -64,10 +68,22 @@ export function pipeline404(page, context) {
64
68
  // drop any pre-existing robots meta so ours is the single source of truth
65
69
  // (scan.js's stripper exists for exactly this - one regex, one owner)
66
70
  html = stripRobotsMeta(html);
67
- html = html.replace(/<\/head>/i, '<meta name="robots" content="noindex, nofollow">\n</head>');
68
- fs.writeFileSync(page.file, html);
69
71
  page.html = html;
72
+ fs.writeFileSync(page.file, html);
70
73
 
71
74
  injectPage(page, context);
75
+
76
+ // Assert the noindex AFTER injectPage, not before: injectPage's rerun
77
+ // hygiene (stripInjectedRegions) deletes everything from the
78
+ // `<!-- freestruct -->` marker to `</head>`, so a noindex inserted before
79
+ // the marker on run 1 fell INSIDE that span once the marker existed on
80
+ // run 2 and was stripped - a second `freestruct` run silently dropped the
81
+ // 404 noindex and the audit then failed the page. Re-assert it here, past
82
+ // the strip, and it stays put across reruns.
83
+ let out = fs.readFileSync(page.file, 'utf8');
84
+ out = stripRobotsMeta(out);
85
+ out = out.replace(/<\/head>/i, '<meta name="robots" content="noindex, nofollow">\n</head>');
86
+ fs.writeFileSync(page.file, out);
87
+ page.html = out;
72
88
  return page;
73
89
  }
package/engine/scan.js CHANGED
@@ -43,6 +43,37 @@ export function is404File(rel) {
43
43
  return /(^|\/)404\.html?$/i.test(p);
44
44
  }
45
45
 
46
+ /**
47
+ * Collapse pages that map to the SAME slug (one URL) to a single entry, so a
48
+ * tree carrying both `a.html` and `a.htm` (or `index.html` and `index.htm` in
49
+ * one directory) does not list one URL twice in sitemap.xml / feed.xml /
50
+ * search.json / llms.txt. Deterministic winner: `.html` over `.htm` (the
51
+ * pretty-URL resolver's own preference), then the lexicographically smaller
52
+ * rel path. Order is preserved by first appearance.
53
+ */
54
+ export function dedupeBySlug(pages) {
55
+ const bySlug = new Map();
56
+ for (const page of pages) {
57
+ const current = bySlug.get(page.slug);
58
+ if (!current) { bySlug.set(page.slug, page); continue; }
59
+ const better = (a, b) => {
60
+ const aHtml = /\.html$/i.test(a.rel) ? 0 : 1;
61
+ const bHtml = /\.html$/i.test(b.rel) ? 0 : 1;
62
+ if (aHtml !== bHtml) return aHtml < bHtml ? a : b;
63
+ return a.rel <= b.rel ? a : b;
64
+ };
65
+ bySlug.set(page.slug, better(current, page));
66
+ }
67
+ const seen = new Set();
68
+ const out = [];
69
+ for (const page of pages) {
70
+ if (seen.has(page.slug)) continue;
71
+ seen.add(page.slug);
72
+ out.push(bySlug.get(page.slug));
73
+ }
74
+ return out;
75
+ }
76
+
46
77
  /** Robots noindex detection on (possibly injected) HTML. */
47
78
  export function htmlIsNoindex(html) {
48
79
  const m = html.match(/<meta[^>]*name=["']robots["'][^>]*>/i);
@@ -54,17 +85,81 @@ export function stripRobotsMeta(html) {
54
85
  return html.replace(/<meta[^>]*name=["']robots["'][^>]*>/i, '');
55
86
  }
56
87
 
57
- /** Extract SEO candidates from raw page HTML. (kept from 0.3.x semantics) */
88
+ /**
89
+ * Decode the HTML entities an SSG routinely writes into <title>/meta content
90
+ * (&amp; &lt; &gt; &quot; &#39;). Without this, an entity-encoded source fact
91
+ * flowed verbatim into the generated artifacts: the feed re-escaped the
92
+ * already-escaped '&amp;' into '&amp;amp;' (double-encoded text a reader
93
+ * sees as '&amp;'), and search.json/llms.txt carried raw entities into
94
+ * plain-text/JSON contexts that render them literally.
95
+ */
96
+ // Common named entities (the ones that realistically appear in titles and
97
+ // descriptions) plus numeric forms. A title carrying '&#8217;' or '&mdash;'
98
+ // was left literal by the old five-entry map, so the feed double-escaped it
99
+ // ('&amp;#8217;', which renders as the literal entity text) and the length
100
+ // graders counted the markup instead of the character.
101
+ const NAMED_ENTITIES = {
102
+ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&',
103
+ nbsp: '\u00a0', mdash: '\u2014', ndash: '\u2013', hellip: '\u2026',
104
+ lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201c', rdquo: '\u201d',
105
+ copy: '\u00a9', reg: '\u00ae', trade: '\u2122', deg: '\u00b0',
106
+ times: '\u00d7', laquo: '\u00ab', raquo: '\u00bb', middot: '\u00b7',
107
+ bull: '\u2022', euro: '\u20ac', pound: '\u00a3', yen: '\u00a5',
108
+ cent: '\u00a2', sect: '\u00a7', para: '\u00b6', dagger: '\u2020',
109
+ plusmn: '\u00b1', frac12: '\u00bd', frac14: '\u00bc', frac34: '\u00be',
110
+ };
111
+
112
+ export function decodeEntities(value) {
113
+ return String(value == null ? '' : value)
114
+ // Numeric forms first: decimal '&#8217;' and hex '&#x2019;'. Doing these
115
+ // before the named pass keeps single-pass semantics ('&amp;#65;' stays
116
+ // the literal '&#65;' after the amp pass, never '<').
117
+ .replace(/&#x([0-9a-f]+);/gi, (m, hex) => {
118
+ const cp = parseInt(hex, 16);
119
+ return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
120
+ })
121
+ .replace(/&#(\d+);/g, (m, dec) => {
122
+ const cp = parseInt(dec, 10);
123
+ return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
124
+ })
125
+ .replace(/&([a-z][a-z0-9]*);/gi, (m, name) => {
126
+ const key = name.toLowerCase();
127
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, key) ? NAMED_ENTITIES[key] : m;
128
+ });
129
+ }
130
+
131
+ // Quoted-attribute reader (same shape audit.js owns): the closing quote must
132
+ // MATCH the opening one, so a value holding the other quote kind (an
133
+ // apostrophe in a description) is read whole instead of truncated at it.
134
+ function attrValue(tag, attr) {
135
+ const quoted = tag.match(new RegExp(`\\b${attr}=(["'])([\\s\\S]*?)\\1`, 'i'));
136
+ if (quoted) return quoted[2];
137
+ const bare = tag.match(new RegExp(`\\b${attr}=([^\\s>]+)`, 'i'));
138
+ return bare ? bare[1] : null;
139
+ }
140
+
141
+ /**
142
+ * Extract SEO candidates from raw page HTML. Attribute-ORDER tolerant and
143
+ * quote-agnostic (Pass 26.21): the old fixed `name="title" content="..."`
144
+ * shape missed a reordered or single-quoted meta, so the page silently fell
145
+ * back to the site-level value. Values are entity-decoded so downstream
146
+ * artifacts (feed/search/llms) receive plain text, not raw entities.
147
+ */
58
148
  export function extractTitle(html) {
59
- const m = html.match(/<meta name="title" content="([^"]+)"/i);
60
- if (m) return m[1];
61
- const t = html.match(/<title>([^<]+)<\/title>/i);
62
- return t ? t[1].split(' | ')[0] : null;
149
+ const meta = html.match(/<meta\b[^>]*\bname=["']title["'][^>]*>/i);
150
+ if (meta) {
151
+ const v = attrValue(meta[0], 'content');
152
+ if (v) return decodeEntities(v);
153
+ }
154
+ const t = html.match(/<title[^>]*>([^<]+)<\/title>/i);
155
+ return t ? decodeEntities(t[1]).split(' | ')[0] : null;
63
156
  }
64
157
 
65
158
  export function extractDescription(html) {
66
- const m = html.match(/<meta name="description" content="([^"]+)"/i);
67
- return m ? m[1] : null;
159
+ const meta = html.match(/<meta\b[^>]*\bname=["']description["'][^>]*>/i);
160
+ if (!meta) return null;
161
+ const v = attrValue(meta[0], 'content');
162
+ return v ? decodeEntities(v) : null;
68
163
  }
69
164
 
70
165
  /**
@@ -92,11 +187,33 @@ export function hasSeoType(html, type) {
92
187
  return /<link[^>]+rel=["']canonical["']/i.test(html);
93
188
  }
94
189
  if (type === 'description') {
95
- return /<meta[^>]*name=["']description["']/.test(html);
190
+ // Case-insensitive like every branch above: an SSG/author emitting an
191
+ // upper- or mixed-case meta (<META NAME="description" ...>, valid HTML)
192
+ // missed this gate, so freestruct injected a SECOND description and its
193
+ // own audit then failed the page for the duplicate it caused.
194
+ return /<meta[^>]*name=["']description["']/i.test(html);
96
195
  }
97
196
  return false;
98
197
  }
99
198
 
199
+ /**
200
+ * Visible text of an HTML fragment: script/style bodies and comments drop
201
+ * first, then every tag, whitespace collapses, and entities decode. Shared by
202
+ * the search index and the reading-time estimator - both count page prose, and
203
+ * both were inflated by raw JS/CSS when each stripped tags alone.
204
+ */
205
+ export function htmlToText(html) {
206
+ return decodeEntities(
207
+ String(html)
208
+ .replace(/<script[\s\S]*?<\/script\s*>/gi, ' ')
209
+ .replace(/<style[\s\S]*?<\/style\s*>/gi, ' ')
210
+ .replace(/<!--[\s\S]*?-->/g, ' ')
211
+ .replace(/<[^>]+>/g, ' ')
212
+ .replace(/\s+/g, ' ')
213
+ .trim(),
214
+ );
215
+ }
216
+
100
217
  /**
101
218
  * Snapshot every page once, before freestruct writes anything.
102
219
  * mtime is captured HERE so sitemap lastmod / RSS pubDate reflect the last
@@ -150,7 +267,16 @@ export function computeBuildHash(config, pages) {
150
267
  const stable = (html) => html
151
268
  .replace(/<meta[^>]*name="(freestruct-build|last-modified|reading-time|robots)"[^>]*>/gi, '')
152
269
  .replace(/<!-- freestruct -->[\s\S]*?(?=<\/head>)/i, '')
153
- .replace(/\?v=[0-9a-f]{8}/g, '')
270
+ // Body-start/footer snippets (marker-wrapped by seo.js) are the tool's own
271
+ // output; strip them too or a rerun's body text differs from run 1 and the
272
+ // content-derived hash churns every pass.
273
+ .replace(/<!-- freestruct:snippet -->[\s\S]*?<!-- \/freestruct:snippet -->/gi, '')
274
+ // The tableWrap pass (seo.js addTableWrap) wraps each outermost table in a
275
+ // tool-owned div AFTER the hash is computed, so a rerun's page content
276
+ // carries the wrapper the first run did not - without this strip the
277
+ // content-derived hash churned on every pass for any site with a table.
278
+ .replace(/<div class="fs-table-wrap"[^>]*>\s*(<table[\s\S]*?<\/table>)\s*<\/div>/gi, '$1')
279
+ .replace(/[?&]v=[0-9a-f]{8}/g, '')
154
280
  .replace(/ loading="lazy"/g, '');
155
281
  for (const page of pages.filter(p => !p.is404).sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))) {
156
282
  h.update(page.rel);
package/engine/search.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { pageUrlFor } from './urls.js';
11
+ import { extractTitle, extractDescription, htmlToText } from './scan.js';
11
12
 
12
13
  export function generateSearchIndex(pages, config, outputDir) {
13
14
  const index = [];
@@ -19,18 +20,20 @@ export function generateSearchIndex(pages, config, outputDir) {
19
20
  // would search; raw file content at this point is the injected page).
20
21
  const html = fs.readFileSync(page.file, 'utf8');
21
22
 
22
- const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
23
- const descMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
24
- const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
23
+ // Entity-decoded, tag-free extraction (Pass 26.21 / 26.24): search.json is
24
+ // JSON consumed by JS, where '&amp;' renders literally as '&amp;' and a
25
+ // stray '<em>' renders as nothing. htmlToText drops script/style bodies
26
+ // and comments, strips every tag, and decodes entities, so the index
27
+ // carries plain page text (the old tag-only strip shipped raw JS/CSS, and
28
+ // the heading pass returned the h1's inner HTML with its tags).
29
+ const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
25
30
 
26
31
  const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
27
- const content = bodyMatch
28
- ? bodyMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500)
29
- : '';
32
+ const content = bodyMatch ? htmlToText(bodyMatch[1]).slice(0, 500) : '';
30
33
 
31
- const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file).replace(/\.html?$/i, '');
32
- const description = descMatch ? descMatch[1] : '';
33
- const heading = h1Match ? h1Match[1] : title;
34
+ const title = extractTitle(html) || path.basename(page.file).replace(/\.html?$/i, '');
35
+ const description = extractDescription(html) || '';
36
+ const heading = h1Match ? htmlToText(h1Match[1]) : title;
34
37
  const url = pageUrlFor(config, page.slug);
35
38
 
36
39
  index.push({ title, heading, description, content, url });