freestruct 1.0.0 → 1.1.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';
38
+ import { getHtmlFiles, buildPages, computeBuildHash, htmlIsNoindex, hasSeoType } from './scan.js';
36
39
  import { injectPage, addReadingTime, addLastModified, addLazyLoading } 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,22 @@ 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
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));
52
66
 
53
67
  export const EXIT_OK = 0;
54
68
  export const EXIT_CONFIG = 1;
55
69
  export const EXIT_TEMPLATE = 2;
56
70
  export const EXIT_OUTPUT = 3;
71
+ export const EXIT_HOOK = 4;
57
72
 
58
73
  function fail(message, code = EXIT_CONFIG) {
59
74
  console.error('[freestruct] ' + message);
@@ -84,10 +99,23 @@ freestruct -- post-build SEO layer for static doc sites
84
99
  freestruct init [dir] Scaffold the consumer kit (starter config +
85
100
  template copies) into <dir>/docs; the default dir
86
101
  is the cwd. Existing files are never clobbered.
102
+ freestruct audit [--json] [--external] [outputDir]
103
+ Verify the BAKED output (per-page head hygiene +
104
+ sitemap/robots/404-noindex contract) without
105
+ touching it; exit 0 clean / 1 findings. Works
106
+ against any SSG's build (issue #18). --external
107
+ also HEAD-checks up to 50 unique external hrefs
108
+ (warn-grade; needs a live network).
109
+ freestruct indexnow [--dry-run] [outputDir]
110
+ POST the baked sitemap URL list to api.indexnow.
111
+ org (Bing/Seznam/Yandex). POST-deploy step: the
112
+ key file emitted during the run must be live.
113
+ --dry-run prints the request body for CI.
87
114
  freestruct help (--help) This text.
88
115
 
89
116
  Flags: --version | --strict | --tolerant
90
117
  Exit codes: 0 ok | 1 config fatal | 2 template missing | 3 output dir missing
118
+ | 4 hook failure | 1 audit findings (audit subcommand)
91
119
  `;
92
120
 
93
121
  function printHelp() {
@@ -145,6 +173,16 @@ function cmdInit(dirArg) {
145
173
  const STRICT = _flags.includes('--strict');
146
174
 
147
175
  export function runInject(options = {}) {
176
+ // Subcommand exclusivity: validate/init/help/audit/indexnow dispatch and
177
+ // exit during the module body - the full pass must never run alongside
178
+ // them (with --external the only path that lives past the import, this
179
+ // guard is what keeps the engine from re-injecting mid-audit).
180
+ 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
+ }
148
186
  const cwd = options.cwd || process.cwd();
149
187
  console.log('freestruct: Loading config...');
150
188
 
@@ -161,7 +199,29 @@ export function runInject(options = {}) {
161
199
  ? path.resolve(cwd, _positional)
162
200
  : path.resolve(configDir, config.outputDir || '_site');
163
201
 
202
+ // File-at-path hardening (Pass 26.13): the existence check alone let an
203
+ // outputDir pointing at a FILE through, and the tree walk crashed with an
204
+ // uncaught ENOTDIR stack - fatal errors need a named cause + exit code.
164
205
  if (!fs.existsSync(outputDir)) fail('Output directory not found: ' + outputDir, EXIT_OUTPUT);
206
+ else if (!fs.statSync(outputDir).isDirectory()) fail('Output directory is not a directory: ' + outputDir, EXIT_OUTPUT);
207
+ // Source-tree hardening (Pass 26.15, tightened after CI): the outputDir
208
+ // positional accepts any directory, and pointing it at an SSG SOURCE tree
209
+ // (this repo's docs/, today - committed template copies and layout HTML
210
+ // injected in place) poisons files silently. Sentinels are ONLY staging
211
+ // dirs: Jekyll never exports underscore dirs into _site, but it DOES copy
212
+ // bare files like the config into it (CI hit the over-fire), so config
213
+ // files are not evidence. Staging dirs inside a built tree mean the
214
+ // caller aimed at source: abort loudly with the fix spelled out.
215
+ const sourceMarkers = [
216
+ ['_layouts', '_layouts/ staging'],
217
+ ['_freestruct', 'template staging'],
218
+ ];
219
+ for (const [marker, meaning] of sourceMarkers) {
220
+ if (fs.existsSync(path.join(outputDir, marker))) {
221
+ fail('Refusing to inject: ' + outputDir + ' looks like an SSG source tree (' + meaning +
222
+ ' found inside). Point freestruct at the BUILT output instead (e.g. docs/_site).', EXIT_OUTPUT);
223
+ }
224
+ }
165
225
  // Template resolution: user-staged first (docs/_freestruct next to the
166
226
  // config), then the PACKAGE-shipped copy (npm install ships engine/_freestruct
167
227
  // - repo-shaped runs always hit the staged one, fresh-machine installs fall
@@ -195,8 +255,29 @@ export function runInject(options = {}) {
195
255
  const pages = pagesForHash;
196
256
  const sitePages = pages.filter(p => !p.is404);
197
257
 
198
- // SEO pipeline over every regular page
199
- const injectCtx = { config, template, buildHash, snippetDir };
258
+ // Per-page OG image generation (issue #22) runs BEFORE injection so the
259
+ // SEO pipeline can wire the fresh PNGs into og/twitter (missing-only:
260
+ // pages with their own og:image or a per-page override keep theirs).
261
+ const generation = resolveOgImageGeneration(config, configDir);
262
+ const generatedImages = {};
263
+ if (generation) {
264
+ for (const page of sitePages) {
265
+ // Noindex pages are excluded from all artifacts (issue #5 posture):
266
+ // a robots-blocked page needs no share preview.
267
+ if (htmlIsNoindex(page.html)) continue;
268
+ // Pages that ship their own og:image keep theirs (missing-only,
269
+ // the pitch's own rule); a per-page ogImage override also wins
270
+ // because the seo seam resolves overrides before generation.
271
+ if (hasSeoType(page.html, 'og')) continue;
272
+ const rendered = renderPageOgImage(generation, page, config, outputDir);
273
+ if (rendered) generatedImages[page.slug] = rendered;
274
+ }
275
+ }
276
+
277
+ // SEO pipeline over every regular page. The injectWarnings array is the
278
+ // shuttle for per-page image-policy offenses (issue #20): report-only by
279
+ // default, graduated to build failures by the --strict block below.
280
+ const injectCtx = { config, template, buildHash, snippetDir, injectWarnings: [], generatedImages };
200
281
  for (const page of sitePages) injectPage(page, injectCtx);
201
282
 
202
283
  // 404 pipeline: generated when absent, user-supplied folded into the
@@ -242,17 +323,30 @@ export function runInject(options = {}) {
242
323
  // opt-in search index (owner decision: search.json is opt-in as of Phase 2)
243
324
  if (config.searchIndex === true) generateSearchIndex(indexablePages, config, outputDir);
244
325
 
326
+ // opt-in llms.txt (SEO-gap pass, config.llms): the AI-consumable docs
327
+ // summary, built from the same indexable page set sitemap/feed honor
328
+ // (noindex stays honored everywhere). Missing-only: an existing file
329
+ // is never overwritten.
330
+ if (config.llms === true) generateLlms(indexablePages, config, outputDir);
331
+
245
332
  // --strict: report-only classes become build failures (Phase 0 contract)
246
333
  if (STRICT) {
247
334
  const problems = [];
248
335
  if (brokenLinks.length > 0) {
249
336
  problems.push(brokenLinks.length + ' broken internal link(s)');
250
337
  }
338
+ // issue #20: SVG og:image offenses collected by the SEO pipeline (the
339
+ // 404 pipeline included, same ctx) - report-only classes graduate here,
340
+ // same as broken links.
341
+ if (injectCtx.injectWarnings.length > 0) {
342
+ problems.push(injectCtx.injectWarnings.length + ' SVG og:image value(s) (share previews blank; use a raster image or allowSvg: true)');
343
+ }
251
344
  // noindex cannot leak into generated artifacts anymore (filtered above);
252
345
  // the gate stays as a defensive tripwire for future artifact changes.
253
346
  if (problems.length > 0) {
254
347
  console.log('[freestruct] strict mode: build failed:');
255
348
  for (const problem of problems) console.log(' - ' + problem);
349
+ for (const w of injectCtx.injectWarnings) console.log(' - [issue #20] ' + w);
256
350
  for (const b of brokenLinks) console.log(' BROKEN: ' + b.file + ' -> ' + b.link);
257
351
  process.exit(1);
258
352
  }
@@ -262,6 +356,20 @@ export function runInject(options = {}) {
262
356
  // purge hooks, if configured (gated by runHooks)
263
357
  if (config.cacheBusting?.purge) runPurgeHooks(config, buildHash, outputDir);
264
358
 
359
+ // indexnow key file (issue #19): emitted during the run so the deployed
360
+ // keyLocation resolves (the engine that owns the sitemap ships the key
361
+ // file next to it). Submission is a separate subcommand - post-deploy.
362
+ const indexNowContext = resolveIndexNowKey(config, configDir);
363
+ if (indexNowContext) emitIndexNowKeyFile(indexNowContext.key, outputDir);
364
+
365
+ // postBuild hooks (issue #21): general commands chained after the pass,
366
+ // same runHooks gate as the purges. Unlike purges, a nonzero hook exit is
367
+ // a build failure: exit 4, the first failing hook wins.
368
+ const hookFailures = runPostBuildHooks(config, buildHash, outputDir);
369
+ if (hookFailures.length > 0) {
370
+ fail(hookFailures.length + ' postBuild hook(s) failed (exit ' + hookFailures.map(f => f.status).join(', ') + ')', EXIT_HOOK);
371
+ }
372
+
265
373
  console.log('freestruct: Done (' + buildHash + ')');
266
374
  }
267
375
 
@@ -272,8 +380,111 @@ export function runInject(options = {}) {
272
380
  if (_flags.includes('--version')) { printVersion(); process.exit(0); }
273
381
  // `help` is the subcommand; --help is the flag alias (both print the same text)
274
382
  if (_firstArg === 'help' || _argv.includes('--help')) { printHelp(); process.exit(EXIT_OK); }
383
+ // ---------- audit subcommand (issue #18) ----------
384
+
385
+ // `freestruct audit [outputDir] [--json]`: verify the BAKED output (heads +
386
+ // site artifacts) after any freestruct run. Reads only the directory, so it
387
+ // works against any SSG's build. Exit 0 clean / exit 1 findings; a missing
388
+ // output dir is the familiar exit 3. The config is discovered the same way
389
+ // the run walks up from cwd (it maps basePath for the on-disk asset checks);
390
+ // a configless audit just runs without the disk-mapping extras.
391
+ 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
+ }
400
+
401
+ let config = null;
402
+ let configDir = process.cwd();
403
+ try {
404
+ const loaded = loadConfig(process.cwd());
405
+ config = loaded.config;
406
+ configDir = loaded.configDir;
407
+ } catch (e) {
408
+ console.log('[freestruct] audit: config not discoverable (' + e.message.split('\n')[0] + ') - running without the on-disk asset mapping');
409
+ }
410
+
411
+ const outputDir = explicitDir
412
+ ? path.resolve(process.cwd(), explicitDir)
413
+ : path.resolve(configDir, (config && config.outputDir) || '_site');
414
+
415
+ if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) {
416
+ fail('Output directory not found (or not a directory): ' + outputDir, EXIT_OUTPUT);
417
+ }
418
+
419
+ const result = auditOutput({ outputDir, config, json: useJson && !useExternal, capture: useJson && useExternal });
420
+ // The no-external path exits HERE, synchronously, exactly like the pre-
421
+ // --external audit: a sync-crashing auditOutput must never let the
422
+ // engine's own full pass run on an audit invocation.
423
+ if (!useExternal) process.exit(result.fails.length > 0 ? 1 : EXIT_OK);
424
+ // External mode is the only async path: collect the network warn rows
425
+ // after the local audit, then decide the exit code.
426
+ const { warns: externalWarns } = await auditExternalLinks({ outputDir, json: useJson });
427
+ if (useJson) {
428
+ console.log(JSON.stringify({ ok: result.fails.length === 0, pages: result.pages, fails: result.fails, warns: [...result.warns, ...externalWarns] }));
429
+ } else {
430
+ const totalWarns = result.warns.length + externalWarns.length;
431
+ console.log(result.fails.length === 0
432
+ ? '[freestruct] audit: 0 failures, ' + totalWarns + ' warning(s) - clean'
433
+ : '[freestruct] audit: ' + result.fails.length + ' failure(s), ' + totalWarns + ' warning(s) - exiting 1');
434
+ }
435
+ process.exit(result.fails.length > 0 ? 1 : EXIT_OK);
436
+ }
437
+
438
+ // ---------- indexnow subcommand (issue #19) ----------
439
+
440
+ // `freestruct indexnow [--dry-run] [outputDir]`: read the baked sitemap.xml
441
+ // and POST its URL list to api.indexnow.org. POST-deploy by design - the
442
+ // keyLocation file emitted during the run must be LIVE for the engines to
443
+ // verify ownership, so this is documented as the post-deploy step (CI often
444
+ // wants --dry-run at build time). Exit 0 accepted/dry-run | 1 failure.
445
+ 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
+ }
453
+
454
+ let config = null;
455
+ let configDir = process.cwd();
456
+ try {
457
+ const loaded = loadConfig(process.cwd());
458
+ config = loaded.config;
459
+ configDir = loaded.configDir;
460
+ } catch (e) {
461
+ fail(e.message, EXIT_CONFIG);
462
+ }
463
+ if (config.indexNow === undefined || config.indexNow === null || config.indexNow === false) {
464
+ fail('indexnow: indexNow is not configured in the config (set indexNow: true or { key: "<32-hex>" })', EXIT_CONFIG);
465
+ }
466
+ if (typeof config.indexNow === 'string' || (config.indexNow && typeof config.indexNow === 'object' && config.indexNow.key !== undefined)) {
467
+ const key = typeof config.indexNow === 'string' ? config.indexNow : config.indexNow.key;
468
+ if (!isValidIndexNowKey(key)) {
469
+ fail('indexnow: the configured key is not 32 lowercase hex chars - refusing to submit a broken ownership signal', EXIT_CONFIG);
470
+ }
471
+ }
472
+
473
+ const outputDir = explicitDir
474
+ ? path.resolve(process.cwd(), explicitDir)
475
+ : path.resolve(configDir, config.outputDir || '_site');
476
+ if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) {
477
+ fail('Output directory not found (or not a directory): ' + outputDir + ' - run freestruct first (indexnow reads the baked sitemap)', EXIT_OUTPUT);
478
+ }
479
+
480
+ const result = await submitIndexNow({ config, configDir, outputDir, dryRun });
481
+ process.exit(result.ok ? EXIT_OK : 1);
482
+ }
483
+
275
484
  if (_firstArg === 'validate') cmdValidate();
276
485
  if (_firstArg === 'init') cmdInit(_argv[_argv.indexOf('init') + 1]);
486
+ if (_firstArg === 'audit') cmdAudit().catch((e) => fail('audit: ' + (e && e.message ? e.message : e)));
487
+ if (_firstArg === 'indexnow') { cmdIndexNow(); }
277
488
 
278
489
  // Main-module detection: npm's .bin shim is a symlink, but import.meta.url
279
490
  // resolves the target's real path. Comparing only the raw argv[1] made real
@@ -7,12 +7,28 @@ 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
18
  export function checkLinks(files, outputDir) {
12
19
  const broken = [];
13
20
 
14
21
  for (const file of files) {
15
- const html = fs.readFileSync(file, 'utf8');
22
+ // Payload stripping (Pass 26.14): inline JS template strings
23
+ // ("<a href=" + url + ">" shapes) are NOT links; rouge-fenced code
24
+ // TEACHES link shapes (the style guide's <a href="guides/foo">) -
25
+ // neither is a route. Scan the document with script, style, pre and
26
+ // code payloads removed (real nav hrefs never live in any of them).
27
+ const html = fs.readFileSync(file, 'utf8')
28
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
29
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
30
+ .replace(/<pre[\s\S]*?<\/pre>/gi, '')
31
+ .replace(/<code[\s\S]*?<\/code>/gi, '');
16
32
  const baseDir = path.dirname(file);
17
33
 
18
34
  const linkRegex = /href="([^"#]+)(?:#[^"]*)?"/g;
@@ -20,18 +36,42 @@ export function checkLinks(files, outputDir) {
20
36
  while ((match = linkRegex.exec(html)) !== null) {
21
37
  const link = match[1];
22
38
 
23
- // Skip external, mailto, tel
24
- if (link.startsWith('http') || link.startsWith('mailto:') || link.startsWith('tel:') || link.startsWith('//')) continue;
39
+ // Skip external, absolute-protocol, and ANY scheme-shaped target
40
+ // (data:, javascript:, tel:, mailto:, ...): schemes are not routes.
41
+ if (/^[a-z][a-z0-9+.-]*:/i.test(link) || link.startsWith('//')) continue;
25
42
 
26
- const absPath = path.join(baseDir, link);
43
+ // Root-relative hrefs (the whole nav menu: "/guides/cli") resolve
44
+ // against the OUTPUT ROOT - path.join(baseDir, '/x') APPENDS, it never
45
+ // resets, which turned every menu link on a nested page into a miss
46
+ // (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);
27
50
  const normalized = path.normalize(absPath);
28
51
 
29
- let checkPath = normalized;
30
- if (fs.existsSync(path.join(normalized, 'index.html'))) {
31
- checkPath = path.join(normalized, 'index.html');
52
+ // Targeted file link: extension decides the check shape (case-
53
+ // insensitive .html/.htm - Pass 26.10, mixed-case SSG output is a
54
+ // page like any other).
55
+ if (/\.html?$/i.test(normalized)) {
56
+ if (!fs.existsSync(normalized)) {
57
+ broken.push({ file: path.relative(outputDir, file), link });
58
+ }
59
+ continue;
32
60
  }
33
61
 
34
- if (normalized.endsWith('.html') && !fs.existsSync(normalized)) {
62
+ // Pretty-URL link: resolve like the route model - directory index
63
+ // first, then the .html/.htm sibling. Asset-like targets (their last
64
+ // segment carries an extension that is not a page route: .css, .js,
65
+ // .png, .json, ...) are the host's business and stay out of the
66
+ // link checker.
67
+ const lastSegment = normalized.split(path.sep).pop() || '';
68
+ if (lastSegment.includes('.')) continue;
69
+
70
+ const dirIndex = fs.existsSync(path.join(normalized, 'index.html'))
71
+ || fs.existsSync(path.join(normalized, 'index.htm'));
72
+ const prettySibling = fs.existsSync(path.join(normalized + '.html'))
73
+ || fs.existsSync(path.join(normalized + '.htm'));
74
+ if (!dirIndex && !prettySibling) {
35
75
  broken.push({ file: path.relative(outputDir, file), link });
36
76
  }
37
77
  }
package/engine/llms.js ADDED
@@ -0,0 +1,52 @@
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
+ * Write llms.txt into the output dir. Pages only: indexable = non-404,
28
+ * non-noindex (the same gate sitemap/feed honor, so noindex stays honored
29
+ * everywhere). Missing-only: an existing llms.txt (hand-rolled or
30
+ * SSG-written) is never touched.
31
+ */
32
+ export function generateLlms(indexablePages, config, outputDir) {
33
+ const target = path.join(outputDir, 'llms.txt');
34
+ if (fs.existsSync(target)) {
35
+ console.log('[freestruct] llms.txt exists - leaving it untouched');
36
+ return null;
37
+ }
38
+ const lines = [];
39
+ lines.push('# ' + oneline(config.site.name));
40
+ if (config.site.description) lines.push('', '> ' + oneline(config.site.description));
41
+ lines.push('', '## Docs', '');
42
+ for (const page of indexablePages) {
43
+ const url = pageUrlFor(config, page.slug);
44
+ const title = oneline(page.title || page.slug);
45
+ const desc = page.description ? ': ' + oneline(page.description) : '';
46
+ lines.push('- [' + title + '](' + url + ')' + desc);
47
+ }
48
+ fs.writeFileSync(target, lines.join('\n') + '\n');
49
+ const count = indexablePages.length;
50
+ console.log('[freestruct] llms.txt: emitted for ' + count + ' page(s)');
51
+ return target;
52
+ }
@@ -0,0 +1,142 @@
1
+ // engine/ogimage.js - per-page OG image generation (issue #22)
2
+ // Post-render OG-image generation: read each page's title/description (the
3
+ // engine already extracts both), render a branded 1200x630 image per page
4
+ // from a consumer-authored SVG template, write it into the output tree, and
5
+ // hand the per-page source to the SEO pipeline so og/twitter tags wire to
6
+ // it (missing-only intact: pages with their own og:image, or a per-page
7
+ // ogImage override, keep theirs).
8
+ //
9
+ // Rasterization honesty (the pitch's own 'hard part'): PNG cannot be made
10
+ // in pure Node. The rasterizer is the OPTIONAL npm package
11
+ // @resvg/resvg-js - loaded lazily with createRequire and loud-skipped when
12
+ // it is not installed (the run never breaks and never ships a half
13
+ // feature). Pure-Node output shape: SVG rendering is deterministic (same
14
+ // page meta -> same bytes; the build hash stays content-derived). Fonts:
15
+ // system fonts are unreliable in build sandboxes, so ogImageGeneration.font
16
+ // loads a TTF/OTF buffer straight into the rasterizer.
17
+ //
18
+ // Config shape (configDir-relative paths):
19
+ // ogImageGeneration:
20
+ // template: og-template.svg # 1200x630 SVG with {{ogTitle}} /
21
+ // # {{ogDescription}} / {{ogUrl}}
22
+ // font: ./brand/brand-bold.ttf # optional
23
+ // width: 1200 # optional; must be 1200x630 ratio-safe
24
+ // height: 630 # optional
25
+
26
+ import fs from 'fs';
27
+ import path from 'path';
28
+ import { createRequire } from 'module';
29
+ import { escXml } from './xmllike.js';
30
+ import { pageUrlFor } from './urls.js';
31
+
32
+ const _require = createRequire(import.meta.url);
33
+
34
+ /** Directory-slug for the image filename: '/' and nested paths flatten. */
35
+ function imageSlug(page) {
36
+ return page.slug.replace(/^\/+|\/+$/g, '').replace(/\//g, '-') || 'index';
37
+ }
38
+
39
+ /** Lazily resolve the optional rasterizer CLASS; null when not installed. */
40
+ function resolveRasterizer() {
41
+ try {
42
+ const mod = _require('@resvg/resvg-js');
43
+ // CJS shape: { default?: Resvg, Resvg } ; ESM default handled too.
44
+ const ctor = mod.Resvg || mod.default || mod;
45
+ return (typeof ctor === 'function') ? ctor : null;
46
+ } catch (e) {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Validate + resolve the generation block. Returns null when it is not
53
+ * configured; every disqualifying shape logs a loud single warning (config
54
+ * diagnostics already handled key-name mistakes).
55
+ */
56
+ export function resolveOgImageGeneration(config, configDir) {
57
+ const gen = config.ogImageGeneration;
58
+ if (gen === undefined || gen === null || gen === false) return null;
59
+ if (typeof gen !== 'object' || Array.isArray(gen)) {
60
+ console.log('[freestruct] config warning: ogImageGeneration must be a { template, font?, width?, height? } object');
61
+ return null;
62
+ }
63
+ if (typeof gen.template !== 'string' || gen.template === '') {
64
+ console.log('[freestruct] config warning: ogImageGeneration.template missing - no og image generation');
65
+ return null;
66
+ }
67
+ const templatePath = path.resolve(configDir, gen.template);
68
+ if (!fs.existsSync(templatePath)) {
69
+ console.log('[freestruct] config warning: ogImageGeneration.template "'
70
+ + gen.template + '" not found at ' + templatePath + ' - no og image generation');
71
+ return null;
72
+ }
73
+ const width = Number(gen.width ?? 1200);
74
+ const height = Number(gen.height ?? 630);
75
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
76
+ console.log('[freestruct] config warning: ogImageGeneration width/height must be positive numbers - no og image generation');
77
+ return null;
78
+ }
79
+ const fontPath = gen.font ? path.resolve(configDir, gen.font) : null;
80
+ if (gen.font && (!fontPath || !fs.existsSync(fontPath))) {
81
+ console.log('[freestruct] config warning: ogImageGeneration.font "'
82
+ + gen.font + '" not found - the rasterizer will fall back to system fonts (unreliable in build sandboxes)');
83
+ return null;
84
+ }
85
+ const template = fs.readFileSync(templatePath, 'utf8');
86
+ const fontBytes = fontPath ? fs.readFileSync(fontPath) : null;
87
+ if (/<text/i.test(template) && !fontBytes) {
88
+ console.log('[freestruct] ogimage: template carries <text> but no font is configured - add ogImageGeneration.font so build sandboxes without system fonts do not drop titles');
89
+ }
90
+ return { template, templatePath, fontPath, fontBytes, width, height };
91
+ }
92
+
93
+ /**
94
+ * Render one per-page image and hand back the tag-ready facts.
95
+ * Returns null when the rasterizer is not installed (loud skip by caller)
96
+ * or the SVG renders to no bytes.
97
+ */
98
+ export function renderPageOgImage(generation, page, config, outputDir) {
99
+ const Resvg = resolveRasterizer();
100
+ if (!Resvg) {
101
+ if (!renderPageOgImage._skippedLoudly) {
102
+ renderPageOgImage._skippedLoudly = true;
103
+ console.log('[freestruct] ogimage: @resvg/resvg-js is not installed - generation skips loudly'
104
+ + ' (pure-Node cannot rasterize; install it with: npm install --save-dev @resvg/resvg-js)');
105
+ }
106
+ return null;
107
+ }
108
+
109
+ const pageUrl = pageUrlFor(config, page.slug);
110
+ const displayTitle = page.title || page.rel || 'Preview';
111
+ let svg = generation.template
112
+ .split('{{ogTitle}}').join(escXml(displayTitle))
113
+ .split('{{ogDescription}}').join(escXml(page.description || ''))
114
+ .split('{{ogUrl}}').join(escXml(pageUrl));
115
+
116
+ const fontBuffers = generation.fontBytes ? [generation.fontBytes] : undefined;
117
+ let png;
118
+ try {
119
+ const rendered = new Resvg(svg, {
120
+ fitTo: { mode: 'width', value: generation.width },
121
+ font: fontBuffers ? { fontBuffers, loadSystemFonts: false } : { loadSystemFonts: false },
122
+ });
123
+ png = rendered.render().asPng();
124
+ } catch (e) {
125
+ console.log('[freestruct] ogimage: page ' + page.rel + ' failed to rasterize: ' + e.message);
126
+ return null;
127
+ }
128
+
129
+ const name = imageSlug(page);
130
+ const genDir = path.join(outputDir, 'og');
131
+ if (!fs.existsSync(genDir)) fs.mkdirSync(genDir, { recursive: true });
132
+ const file = path.join(genDir, name + '.png');
133
+ fs.writeFileSync(file, png);
134
+
135
+ return {
136
+ file,
137
+ src: '/og/' + name + '.png',
138
+ width: generation.width,
139
+ height: generation.height,
140
+ alt: displayTitle + ' - page preview image',
141
+ };
142
+ }