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/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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// the
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
289
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
382
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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(
|
|
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
|
|
package/engine/linkcheck.js
CHANGED
|
@@ -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
|
-
|
|
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[
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 =
|
|
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
|
}
|
package/engine/notfound.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Decode the HTML entities an SSG routinely writes into <title>/meta content
|
|
90
|
+
* (& < > " '). Without this, an entity-encoded source fact
|
|
91
|
+
* flowed verbatim into the generated artifacts: the feed re-escaped the
|
|
92
|
+
* already-escaped '&' into '&amp;' (double-encoded text a reader
|
|
93
|
+
* sees as '&'), 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 '’' or '—'
|
|
98
|
+
// was left literal by the old five-entry map, so the feed double-escaped it
|
|
99
|
+
// ('&#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 '’' and hex '’'. Doing these
|
|
115
|
+
// before the named pass keeps single-pass semantics ('&#65;' stays
|
|
116
|
+
// the literal 'A' 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
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Entity-decoded, tag-free extraction (Pass 26.21 / 26.24): search.json is
|
|
24
|
+
// JSON consumed by JS, where '&' renders literally as '&' 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 =
|
|
32
|
-
const description =
|
|
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 });
|