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/README.md +101 -7
- package/engine/_freestruct/inject-brand.html +1 -5
- package/engine/audit.js +355 -0
- package/engine/config.js +207 -1
- package/engine/hooks.js +68 -10
- package/engine/indexnow.js +140 -0
- package/engine/inject.js +218 -7
- package/engine/linkcheck.js +48 -8
- package/engine/llms.js +52 -0
- package/engine/ogimage.js +142 -0
- package/engine/scan.js +20 -11
- package/engine/search.js +1 -1
- package/engine/seo.js +359 -24
- package/engine/urls.js +48 -1
- package/engine/xmllike.js +1 -1
- package/init/VERSION +1 -1
- package/init/docs/_freestruct/inject-brand.html +1 -5
- package/package.json +4 -1
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] |
|
|
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 {
|
|
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
|
-
//
|
|
199
|
-
|
|
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
|
package/engine/linkcheck.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
+
}
|