freestruct 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/inject.js CHANGED
@@ -8,12 +8,14 @@
8
8
  // notfound.js 404 pipeline
9
9
  // linkcheck.js link validation
10
10
  // search.js opt-in search.json
11
- // hooks.js purge hooks
11
+ // hooks.js purge hooks + postBuild hooks
12
+ // audit.js the `freestruct audit` post-build verifier (issue #18)
13
+ // indexnow.js IndexNow key emission + submission (issue #19)
12
14
  //
13
15
  // CLI (Phase 0/2 contract):
14
16
  // --version print the package.json version and exit 0 (works configless;
15
17
  // resolved one level up: engine/ is the package root's sibling dir)
16
- // validate | init [dir] | help subcommands (see the helpers below)
18
+ // validate | init [dir] | audit [--json] [--external] [outputDir] | help subcommands
17
19
  // --strict opt-in: broken internal links exit 1 instead of
18
20
  // shipping green
19
21
  // --tolerant opt-out for CI tolerance: fatal config errors log and exit 0
@@ -22,6 +24,7 @@
22
24
  //
23
25
  // FAST_FAIL exit codes (PRD Phase 2, config-first diagnostics):
24
26
  // 0 success | 1 config fatal | 2 template missing | 3 output dir missing
27
+ // 4 postBuild hook failure (issue #21: nonzero hook exits fail the build)
25
28
  //
26
29
  // Root resolution (Phase 1.2): the config is discovered by walking UP from
27
30
  // the cwd; templates/snippets resolve next to the config file; the output
@@ -32,13 +35,17 @@ import path from 'path';
32
35
  import { pathToFileURL, fileURLToPath } from 'url';
33
36
 
34
37
  import { loadConfig } from './config.js';
35
- import { getHtmlFiles, buildPages, computeBuildHash, htmlIsNoindex } from './scan.js';
36
- 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';
37
40
  import { generateSitemap, generateRobots, generateFeed } from './xmllike.js';
38
41
  import { generate404, pipeline404 } from './notfound.js';
39
42
  import { checkLinks } from './linkcheck.js';
40
43
  import { generateSearchIndex } from './search.js';
41
- import { runPurgeHooks } from './hooks.js';
44
+ import { generateLlms } from './llms.js';
45
+ import { runPurgeHooks, runPostBuildHooks } from './hooks.js';
46
+ import { auditOutput, auditExternalLinks } from './audit.js';
47
+ import { resolveIndexNowKey, emitIndexNowKeyFile, submitIndexNow, isValidIndexNowKey } from './indexnow.js';
48
+ import { resolveOgImageGeneration, renderPageOgImage } from './ogimage.js';
42
49
 
43
50
  const FLAG_VALUES = new Set(['--version', '--strict', '--tolerant']);
44
51
  const _argv = process.argv.slice(2);
@@ -46,14 +53,43 @@ const _flags = _argv.filter(a => FLAG_VALUES.has(a));
46
53
  // Subcommands before the positional output-dir: the first bare word decides
47
54
  // what the run is (validate/init/help mirror prestruct's command surface);
48
55
  // any other bare word stays the outputDir override it always was.
49
- const COMMANDS = new Set(['validate', 'init', 'help']);
56
+ const COMMANDS = new Set(['validate', 'init', 'help', 'audit', 'indexnow']);
50
57
  const _firstArg = _argv.find(a => !a.startsWith('--'));
51
- const _positional = _firstArg && !COMMANDS.has(_firstArg) ? _firstArg : undefined;
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
+ }
52
87
 
53
88
  export const EXIT_OK = 0;
54
89
  export const EXIT_CONFIG = 1;
55
90
  export const EXIT_TEMPLATE = 2;
56
91
  export const EXIT_OUTPUT = 3;
92
+ export const EXIT_HOOK = 4;
57
93
 
58
94
  function fail(message, code = EXIT_CONFIG) {
59
95
  console.error('[freestruct] ' + message);
@@ -84,10 +120,23 @@ freestruct -- post-build SEO layer for static doc sites
84
120
  freestruct init [dir] Scaffold the consumer kit (starter config +
85
121
  template copies) into <dir>/docs; the default dir
86
122
  is the cwd. Existing files are never clobbered.
123
+ freestruct audit [--json] [--external] [outputDir]
124
+ Verify the BAKED output (per-page head hygiene +
125
+ sitemap/robots/404-noindex contract) without
126
+ touching it; exit 0 clean / 1 findings. Works
127
+ against any SSG's build (issue #18). --external
128
+ also HEAD-checks up to 50 unique external hrefs
129
+ (warn-grade; needs a live network).
130
+ freestruct indexnow [--dry-run] [outputDir]
131
+ POST the baked sitemap URL list to api.indexnow.
132
+ org (Bing/Seznam/Yandex). POST-deploy step: the
133
+ key file emitted during the run must be live.
134
+ --dry-run prints the request body for CI.
87
135
  freestruct help (--help) This text.
88
136
 
89
137
  Flags: --version | --strict | --tolerant
90
138
  Exit codes: 0 ok | 1 config fatal | 2 template missing | 3 output dir missing
139
+ | 4 hook failure | 1 audit findings (audit subcommand)
91
140
  `;
92
141
 
93
142
  function printHelp() {
@@ -100,6 +149,7 @@ function printHelp() {
100
149
  * exits EXIT_CONFIG, honoring --tolerant like every other fatal.
101
150
  */
102
151
  function cmdValidate() {
152
+ parseArgs(afterCommand('validate'), { command: 'validate' });
103
153
  let loaded;
104
154
  try {
105
155
  loaded = loadConfig(process.cwd());
@@ -116,7 +166,9 @@ function cmdValidate() {
116
166
  * clobbering an existing file. Mirrors the manual `cp -r node_modules/
117
167
  * freestruct/init/docs .` shape the init kit README describes.
118
168
  */
119
- function cmdInit(dirArg) {
169
+ function cmdInit() {
170
+ const { positionals } = parseArgs(afterCommand('init'), { command: 'init', maxPositionals: 1 });
171
+ const dirArg = positionals[0];
120
172
  const targetRoot = path.resolve(process.cwd(), dirArg || '.');
121
173
  const docsDir = path.join(targetRoot, 'docs');
122
174
  // engine/inject.js -> package root -> init/docs (files list ships init/)
@@ -145,6 +197,17 @@ function cmdInit(dirArg) {
145
197
  const STRICT = _flags.includes('--strict');
146
198
 
147
199
  export function runInject(options = {}) {
200
+ // Subcommand exclusivity: validate/init/help/audit/indexnow dispatch and
201
+ // exit during the module body - the full pass must never run alongside
202
+ // them (with --external the only path that lives past the import, this
203
+ // guard is what keeps the engine from re-injecting mid-audit).
204
+ if (_firstArg && COMMANDS.has(_firstArg)) return;
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];
148
211
  const cwd = options.cwd || process.cwd();
149
212
  console.log('freestruct: Loading config...');
150
213
 
@@ -161,7 +224,29 @@ export function runInject(options = {}) {
161
224
  ? path.resolve(cwd, _positional)
162
225
  : path.resolve(configDir, config.outputDir || '_site');
163
226
 
227
+ // File-at-path hardening (Pass 26.13): the existence check alone let an
228
+ // outputDir pointing at a FILE through, and the tree walk crashed with an
229
+ // uncaught ENOTDIR stack - fatal errors need a named cause + exit code.
164
230
  if (!fs.existsSync(outputDir)) fail('Output directory not found: ' + outputDir, EXIT_OUTPUT);
231
+ else if (!fs.statSync(outputDir).isDirectory()) fail('Output directory is not a directory: ' + outputDir, EXIT_OUTPUT);
232
+ // Source-tree hardening (Pass 26.15, tightened after CI): the outputDir
233
+ // positional accepts any directory, and pointing it at an SSG SOURCE tree
234
+ // (this repo's docs/, today - committed template copies and layout HTML
235
+ // injected in place) poisons files silently. Sentinels are ONLY staging
236
+ // dirs: Jekyll never exports underscore dirs into _site, but it DOES copy
237
+ // bare files like the config into it (CI hit the over-fire), so config
238
+ // files are not evidence. Staging dirs inside a built tree mean the
239
+ // caller aimed at source: abort loudly with the fix spelled out.
240
+ const sourceMarkers = [
241
+ ['_layouts', '_layouts/ staging'],
242
+ ['_freestruct', 'template staging'],
243
+ ];
244
+ for (const [marker, meaning] of sourceMarkers) {
245
+ if (fs.existsSync(path.join(outputDir, marker))) {
246
+ fail('Refusing to inject: ' + outputDir + ' looks like an SSG source tree (' + meaning +
247
+ ' found inside). Point freestruct at the BUILT output instead (e.g. docs/_site).', EXIT_OUTPUT);
248
+ }
249
+ }
165
250
  // Template resolution: user-staged first (docs/_freestruct next to the
166
251
  // config), then the PACKAGE-shipped copy (npm install ships engine/_freestruct
167
252
  // - repo-shaped runs always hit the staged one, fresh-machine installs fall
@@ -179,6 +264,22 @@ export function runInject(options = {}) {
179
264
  if (!fs.existsSync(templatePath)) {
180
265
  fail('Template not found: ' + templatePath + ' (shipped copy also missing at ' + packageTemplateDir + ')', EXIT_TEMPLATE);
181
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
+ }
182
283
  const template = fs.readFileSync(templatePath, 'utf8');
183
284
 
184
285
  const htmlFiles = getHtmlFiles(outputDir);
@@ -195,20 +296,45 @@ export function runInject(options = {}) {
195
296
  const pages = pagesForHash;
196
297
  const sitePages = pages.filter(p => !p.is404);
197
298
 
198
- // SEO pipeline over every regular page
199
- const injectCtx = { config, template, buildHash, snippetDir };
299
+ // Per-page OG image generation (issue #22) runs BEFORE injection so the
300
+ // SEO pipeline can wire the fresh PNGs into og/twitter (missing-only:
301
+ // pages with their own og:image or a per-page override keep theirs).
302
+ const generation = resolveOgImageGeneration(config, configDir);
303
+ const generatedImages = {};
304
+ if (generation) {
305
+ for (const page of sitePages) {
306
+ // Noindex pages are excluded from all artifacts (issue #5 posture):
307
+ // a robots-blocked page needs no share preview.
308
+ if (htmlIsNoindex(page.html)) continue;
309
+ // Pages that ship their own og:image keep theirs (missing-only,
310
+ // the pitch's own rule); a per-page ogImage override also wins
311
+ // because the seo seam resolves overrides before generation.
312
+ if (hasSeoType(page.html, 'og')) continue;
313
+ const rendered = renderPageOgImage(generation, page, config, outputDir);
314
+ if (rendered) generatedImages[page.slug] = rendered;
315
+ }
316
+ }
317
+
318
+ // SEO pipeline over every regular page. The injectWarnings array is the
319
+ // shuttle for per-page image-policy offenses (issue #20): report-only by
320
+ // default, graduated to build failures by the --strict block below.
321
+ const injectCtx = { config, template, buildHash, snippetDir, injectWarnings: [], generatedImages };
200
322
  for (const page of sitePages) injectPage(page, injectCtx);
201
323
 
202
324
  // 404 pipeline: generated when absent, user-supplied folded into the
203
325
  // same full treatment (Phase 1.4). Runs after regular pages so a 404
204
- // 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.
205
331
  const processed404s = [];
206
332
  if (config.generate404 !== false) {
207
- let page404 = pages.find(p => p.is404);
208
- if (!page404) {
333
+ const found404s = pages.filter(p => p.is404);
334
+ if (found404s.length === 0) {
209
335
  const file404 = generate404(config, outputDir, buildHash, template, snippetDir);
210
336
  const html404 = fs.readFileSync(file404, 'utf8');
211
- page404 = {
337
+ found404s.push({
212
338
  file: file404,
213
339
  rel: '404.html',
214
340
  slug: '/404',
@@ -217,9 +343,9 @@ export function runInject(options = {}) {
217
343
  mtime: new Date(),
218
344
  title: html404.match(/<title>([^<]+)<\/title>/i)?.[1]?.split(' | ')[0] || null,
219
345
  description: html404.match(/<meta name="description" content="([^"]+)"/i)?.[1] || null,
220
- };
346
+ });
221
347
  }
222
- processed404s.push(pipeline404(page404, injectCtx));
348
+ for (const page404 of found404s) processed404s.push(pipeline404(page404, injectCtx));
223
349
  }
224
350
 
225
351
  // Late passes over every processed page, 404s included (Phase 1.4)
@@ -227,32 +353,70 @@ export function runInject(options = {}) {
227
353
  if (config.readingTime !== false) for (const page of latePages) addReadingTime(page.file);
228
354
  if (config.lastModified !== false) for (const page of latePages) addLastModified(page.file);
229
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
+ }
230
372
 
231
373
  // noindex is honored by default (owner decision, issue #5):
232
374
  // noindex pages never ship into sitemap/feed/search
233
- 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)));
234
380
 
235
381
  if (config.generateSitemap !== false) generateSitemap(indexablePages, config, outputDir);
236
382
  if (config.generateRobots !== false) generateRobots(config, outputDir);
237
383
  if (config.generateFeed !== false) generateFeed(indexablePages, config, outputDir);
238
384
 
239
- // link check over regular pages (404 targets excluded by design)
240
- 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
+ : [];
241
392
 
242
393
  // opt-in search index (owner decision: search.json is opt-in as of Phase 2)
243
394
  if (config.searchIndex === true) generateSearchIndex(indexablePages, config, outputDir);
244
395
 
396
+ // opt-in llms.txt (SEO-gap pass, config.llms): the AI-consumable docs
397
+ // summary, built from the same indexable page set sitemap/feed honor
398
+ // (noindex stays honored everywhere). Missing-only: an existing file
399
+ // is never overwritten.
400
+ if (config.llms === true) generateLlms(indexablePages, config, outputDir);
401
+
245
402
  // --strict: report-only classes become build failures (Phase 0 contract)
246
403
  if (STRICT) {
247
404
  const problems = [];
248
405
  if (brokenLinks.length > 0) {
249
406
  problems.push(brokenLinks.length + ' broken internal link(s)');
250
407
  }
408
+ // issue #20: SVG og:image offenses collected by the SEO pipeline (the
409
+ // 404 pipeline included, same ctx) - report-only classes graduate here,
410
+ // same as broken links.
411
+ if (injectCtx.injectWarnings.length > 0) {
412
+ problems.push(injectCtx.injectWarnings.length + ' SVG og:image value(s) (share previews blank; use a raster image or allowSvg: true)');
413
+ }
251
414
  // noindex cannot leak into generated artifacts anymore (filtered above);
252
415
  // the gate stays as a defensive tripwire for future artifact changes.
253
416
  if (problems.length > 0) {
254
417
  console.log('[freestruct] strict mode: build failed:');
255
418
  for (const problem of problems) console.log(' - ' + problem);
419
+ for (const w of injectCtx.injectWarnings) console.log(' - [issue #20] ' + w);
256
420
  for (const b of brokenLinks) console.log(' BROKEN: ' + b.file + ' -> ' + b.link);
257
421
  process.exit(1);
258
422
  }
@@ -262,6 +426,20 @@ export function runInject(options = {}) {
262
426
  // purge hooks, if configured (gated by runHooks)
263
427
  if (config.cacheBusting?.purge) runPurgeHooks(config, buildHash, outputDir);
264
428
 
429
+ // indexnow key file (issue #19): emitted during the run so the deployed
430
+ // keyLocation resolves (the engine that owns the sitemap ships the key
431
+ // file next to it). Submission is a separate subcommand - post-deploy.
432
+ const indexNowContext = resolveIndexNowKey(config, configDir);
433
+ if (indexNowContext) emitIndexNowKeyFile(indexNowContext.key, outputDir);
434
+
435
+ // postBuild hooks (issue #21): general commands chained after the pass,
436
+ // same runHooks gate as the purges. Unlike purges, a nonzero hook exit is
437
+ // a build failure: exit 4, the first failing hook wins.
438
+ const hookFailures = runPostBuildHooks(config, buildHash, outputDir);
439
+ if (hookFailures.length > 0) {
440
+ fail(hookFailures.length + ' postBuild hook(s) failed (exit ' + hookFailures.map(f => f.status).join(', ') + ')', EXIT_HOOK);
441
+ }
442
+
265
443
  console.log('freestruct: Done (' + buildHash + ')');
266
444
  }
267
445
 
@@ -270,10 +448,113 @@ export function runInject(options = {}) {
270
448
  // (the pre-change --version module-level check had the same shape). Library
271
449
  // importers pass no such argv and simply skip all of these.
272
450
  if (_flags.includes('--version')) { printVersion(); process.exit(0); }
273
- // `help` is the subcommand; --help is the flag alias (both print the same text)
274
- 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); }
457
+ // ---------- audit subcommand (issue #18) ----------
458
+
459
+ // `freestruct audit [outputDir] [--json]`: verify the BAKED output (heads +
460
+ // site artifacts) after any freestruct run. Reads only the directory, so it
461
+ // works against any SSG's build. Exit 0 clean / exit 1 findings; a missing
462
+ // output dir is the familiar exit 3. The config is discovered the same way
463
+ // the run walks up from cwd (it maps basePath for the on-disk asset checks);
464
+ // a configless audit just runs without the disk-mapping extras.
465
+ async function cmdAudit() {
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];
472
+
473
+ let config = null;
474
+ let configDir = process.cwd();
475
+ try {
476
+ const loaded = loadConfig(process.cwd());
477
+ config = loaded.config;
478
+ configDir = loaded.configDir;
479
+ } catch (e) {
480
+ console.log('[freestruct] audit: config not discoverable (' + e.message.split('\n')[0] + ') - running without the on-disk asset mapping');
481
+ }
482
+
483
+ const outputDir = explicitDir
484
+ ? path.resolve(process.cwd(), explicitDir)
485
+ : path.resolve(configDir, (config && config.outputDir) || '_site');
486
+
487
+ if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) {
488
+ fail('Output directory not found (or not a directory): ' + outputDir, EXIT_OUTPUT);
489
+ }
490
+
491
+ const result = auditOutput({ outputDir, config, json: useJson && !useExternal, capture: useJson && useExternal });
492
+ // The no-external path exits HERE, synchronously, exactly like the pre-
493
+ // --external audit: a sync-crashing auditOutput must never let the
494
+ // engine's own full pass run on an audit invocation.
495
+ if (!useExternal) process.exit(result.fails.length > 0 ? 1 : EXIT_OK);
496
+ // External mode is the only async path: collect the network warn rows
497
+ // after the local audit, then decide the exit code.
498
+ const { warns: externalWarns } = await auditExternalLinks({ outputDir, json: useJson });
499
+ if (useJson) {
500
+ console.log(JSON.stringify({ ok: result.fails.length === 0, pages: result.pages, fails: result.fails, warns: [...result.warns, ...externalWarns] }));
501
+ } else {
502
+ const totalWarns = result.warns.length + externalWarns.length;
503
+ console.log(result.fails.length === 0
504
+ ? '[freestruct] audit: 0 failures, ' + totalWarns + ' warning(s) - clean'
505
+ : '[freestruct] audit: ' + result.fails.length + ' failure(s), ' + totalWarns + ' warning(s) - exiting 1');
506
+ }
507
+ process.exit(result.fails.length > 0 ? 1 : EXIT_OK);
508
+ }
509
+
510
+ // ---------- indexnow subcommand (issue #19) ----------
511
+
512
+ // `freestruct indexnow [--dry-run] [outputDir]`: read the baked sitemap.xml
513
+ // and POST its URL list to api.indexnow.org. POST-deploy by design - the
514
+ // keyLocation file emitted during the run must be LIVE for the engines to
515
+ // verify ownership, so this is documented as the post-deploy step (CI often
516
+ // wants --dry-run at build time). Exit 0 accepted/dry-run | 1 failure.
517
+ async function cmdIndexNow() {
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];
523
+
524
+ let config = null;
525
+ let configDir = process.cwd();
526
+ try {
527
+ const loaded = loadConfig(process.cwd());
528
+ config = loaded.config;
529
+ configDir = loaded.configDir;
530
+ } catch (e) {
531
+ fail(e.message, EXIT_CONFIG);
532
+ }
533
+ if (config.indexNow === undefined || config.indexNow === null || config.indexNow === false) {
534
+ fail('indexnow: indexNow is not configured in the config (set indexNow: true or { key: "<32-hex>" })', EXIT_CONFIG);
535
+ }
536
+ if (typeof config.indexNow === 'string' || (config.indexNow && typeof config.indexNow === 'object' && config.indexNow.key !== undefined)) {
537
+ const key = typeof config.indexNow === 'string' ? config.indexNow : config.indexNow.key;
538
+ if (!isValidIndexNowKey(key)) {
539
+ fail('indexnow: the configured key is not 32 lowercase hex chars - refusing to submit a broken ownership signal', EXIT_CONFIG);
540
+ }
541
+ }
542
+
543
+ const outputDir = explicitDir
544
+ ? path.resolve(process.cwd(), explicitDir)
545
+ : path.resolve(configDir, config.outputDir || '_site');
546
+ if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) {
547
+ fail('Output directory not found (or not a directory): ' + outputDir + ' - run freestruct first (indexnow reads the baked sitemap)', EXIT_OUTPUT);
548
+ }
549
+
550
+ const result = await submitIndexNow({ config, configDir, outputDir, dryRun });
551
+ process.exit(result.ok ? EXIT_OK : 1);
552
+ }
553
+
275
554
  if (_firstArg === 'validate') cmdValidate();
276
- if (_firstArg === 'init') cmdInit(_argv[_argv.indexOf('init') + 1]);
555
+ if (_firstArg === 'init') cmdInit();
556
+ if (_firstArg === 'audit') cmdAudit().catch((e) => fail('audit: ' + (e && e.message ? e.message : e)));
557
+ if (_firstArg === 'indexnow') { cmdIndexNow(); }
277
558
 
278
559
  // Main-module detection: npm's .bin shim is a symlink, but import.meta.url
279
560
  // resolves the target's real path. Comparing only the raw argv[1] made real
@@ -7,31 +7,103 @@ import path from 'path';
7
7
  /**
8
8
  * Check internal links across every page. Returns [{file, link}] for broken
9
9
  * targets and prints the human-readable report (same output as 0.4.x).
10
+ *
11
+ * Two route shapes (Pass 26.10 - the old check acknowledged only literal
12
+ * files): a target WITH an .html/.htm extension is checked as the file;
13
+ * an extension-less (pretty-URL) target resolves through the same route
14
+ * model the walker owns - directory index first (index.html / index.htm),
15
+ * then the .html/.htm sibling file. Assets stay out of the checker's
16
+ * purview (see the shape test above the flags).
10
17
  */
11
- export function checkLinks(files, outputDir) {
18
+ export function checkLinks(files, outputDir, basePath = '') {
12
19
  const broken = [];
13
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
+
14
33
  for (const file of files) {
15
- const html = fs.readFileSync(file, 'utf8');
34
+ // Payload stripping (Pass 26.14): inline JS template strings
35
+ // ("<a href=" + url + ">" shapes) are NOT links; rouge-fenced code
36
+ // TEACHES link shapes (the style guide's <a href="guides/foo">) -
37
+ // neither is a route. Scan the document with script, style, pre and
38
+ // code payloads removed (real nav hrefs never live in any of them).
39
+ const html = fs.readFileSync(file, 'utf8')
40
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
41
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
42
+ .replace(/<pre[\s\S]*?<\/pre>/gi, '')
43
+ .replace(/<code[\s\S]*?<\/code>/gi, '');
16
44
  const baseDir = path.dirname(file);
17
45
 
18
- 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;
19
53
  let match;
20
54
  while ((match = linkRegex.exec(html)) !== null) {
21
- const link = match[1];
55
+ const link = match[2];
22
56
 
23
- // Skip external, mailto, tel
24
- if (link.startsWith('http') || link.startsWith('mailto:') || link.startsWith('tel:') || link.startsWith('//')) continue;
57
+ // Skip external, absolute-protocol, and ANY scheme-shaped target
58
+ // (data:, javascript:, tel:, mailto:, ...): schemes are not routes.
59
+ if (/^[a-z][a-z0-9+.-]*:/i.test(link) || link.startsWith('//')) continue;
25
60
 
26
- const absPath = path.join(baseDir, link);
61
+ // Root-relative hrefs (the whole nav menu: "/guides/cli") resolve
62
+ // against the OUTPUT ROOT - path.join(baseDir, '/x') APPENDS, it never
63
+ // resets, which turned every menu link on a nested page into a miss
64
+ // (the CI --strict run caught it). Relative hrefs stay page-relative.
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);
27
82
  const normalized = path.normalize(absPath);
28
83
 
29
- let checkPath = normalized;
30
- if (fs.existsSync(path.join(normalized, 'index.html'))) {
31
- checkPath = path.join(normalized, 'index.html');
84
+ // Targeted file link: extension decides the check shape (case-
85
+ // insensitive .html/.htm - Pass 26.10, mixed-case SSG output is a
86
+ // page like any other).
87
+ if (/\.html?$/i.test(normalized)) {
88
+ if (!fs.existsSync(normalized)) {
89
+ broken.push({ file: path.relative(outputDir, file), link });
90
+ }
91
+ continue;
32
92
  }
33
93
 
34
- if (normalized.endsWith('.html') && !fs.existsSync(normalized)) {
94
+ // Pretty-URL link: resolve like the route model - directory index
95
+ // first, then the .html/.htm sibling. Asset-like targets (their last
96
+ // segment carries an extension that is not a page route: .css, .js,
97
+ // .png, .json, ...) are the host's business and stay out of the
98
+ // link checker.
99
+ const lastSegment = normalized.split(path.sep).pop() || '';
100
+ if (lastSegment.includes('.')) continue;
101
+
102
+ const dirIndex = fs.existsSync(path.join(normalized, 'index.html'))
103
+ || fs.existsSync(path.join(normalized, 'index.htm'));
104
+ const prettySibling = fs.existsSync(path.join(normalized + '.html'))
105
+ || fs.existsSync(path.join(normalized + '.htm'));
106
+ if (!dirIndex && !prettySibling) {
35
107
  broken.push({ file: path.relative(outputDir, file), link });
36
108
  }
37
109
  }
package/engine/llms.js ADDED
@@ -0,0 +1,63 @@
1
+ // engine/llms.js - llms.txt emission (the SEO-gap pass, config.llms)
2
+ // The AI-consumable docs-summary convention (llmstxt.org shape): one H1, a
3
+ // blockquote tagline, then a Docs section listing every indexable page's
4
+ // title, absolute URL and description. Natural to this engine: the scan
5
+ // pass already carries every page's slug/title/description and the run
6
+ // already owns the indexable (non-noindex) page set - zero new scanning.
7
+ // Generated-only: never overwrites, same contract as sitemap/feed/robots.
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { pageUrlFor } from './urls.js';
12
+
13
+ /**
14
+ * One line of plain text, whatever it was given: a newline (or CR) inside a
15
+ * title/description would split a list entry into two, silently corrupting
16
+ * the summary's shape, and an XML-era control character would ride into the
17
+ * plain-text file as garbage. Bad bytes drop, whitespace collapses.
18
+ */
19
+ function oneline(value) {
20
+ return String(value)
21
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
22
+ .replace(/[\r\n]+/g, ' ')
23
+ .trim();
24
+ }
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
+
37
+ /**
38
+ * Write llms.txt into the output dir. Pages only: indexable = non-404,
39
+ * non-noindex (the same gate sitemap/feed honor, so noindex stays honored
40
+ * everywhere). Missing-only: an existing llms.txt (hand-rolled or
41
+ * SSG-written) is never touched.
42
+ */
43
+ export function generateLlms(indexablePages, config, outputDir) {
44
+ const target = path.join(outputDir, 'llms.txt');
45
+ if (fs.existsSync(target)) {
46
+ console.log('[freestruct] llms.txt exists - leaving it untouched');
47
+ return null;
48
+ }
49
+ const lines = [];
50
+ lines.push('# ' + oneline(config.site.name));
51
+ if (config.site.description) lines.push('', '> ' + oneline(config.site.description));
52
+ lines.push('', '## Docs', '');
53
+ for (const page of indexablePages) {
54
+ const url = pageUrlFor(config, page.slug);
55
+ const title = mdLabel(page.title || page.slug);
56
+ const desc = page.description ? ': ' + oneline(page.description) : '';
57
+ lines.push('- [' + title + '](' + url + ')' + desc);
58
+ }
59
+ fs.writeFileSync(target, lines.join('\n') + '\n');
60
+ const count = indexablePages.length;
61
+ console.log('[freestruct] llms.txt: emitted for ' + count + ' page(s)');
62
+ return target;
63
+ }