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/README.md +135 -119
- package/engine/_freestruct/inject-brand.html +1 -5
- package/engine/audit.js +487 -0
- package/engine/config.js +208 -1
- package/engine/hooks.js +81 -15
- package/engine/indexnow.js +149 -0
- package/engine/inject.js +303 -22
- package/engine/linkcheck.js +83 -11
- package/engine/llms.js +63 -0
- package/engine/notfound.js +21 -5
- package/engine/ogimage.js +142 -0
- package/engine/scan.js +155 -20
- package/engine/search.js +12 -9
- package/engine/seo.js +682 -71
- package/engine/urls.js +48 -1
- package/engine/xmllike.js +7 -4
- package/init/README.md +6 -20
- package/init/VERSION +1 -1
- package/init/docs/_freestruct/inject-brand.html +1 -5
- package/init/docs/freestruct.config.yml +16 -0
- package/package.json +9 -2
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';
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
199
|
-
|
|
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
|
-
|
|
208
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
|
|
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(
|
|
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
|
package/engine/linkcheck.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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[
|
|
55
|
+
const link = match[2];
|
|
22
56
|
|
|
23
|
-
// Skip external,
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
+
}
|