freestruct 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +293 -2
- package/bin/freestruct.mjs +7 -0
- package/engine/_freestruct/inject-body-start.html +2 -0
- package/engine/_freestruct/inject-brand.html +49 -0
- package/engine/_freestruct/inject-footer.html +13 -0
- package/engine/_freestruct/inject-header.html +2 -0
- package/engine/config.js +132 -0
- package/engine/hooks.js +45 -0
- package/engine/inject.js +287 -0
- package/engine/linkcheck.js +49 -0
- package/engine/notfound.js +73 -0
- package/engine/scan.js +151 -0
- package/engine/search.js +41 -0
- package/engine/seo.js +331 -0
- package/engine/urls.js +28 -0
- package/engine/xmllike.js +118 -0
- package/init/README.md +48 -0
- package/init/VERSION +1 -0
- package/init/docs/_freestruct/inject-body-start.html +2 -0
- package/init/docs/_freestruct/inject-brand.html +49 -0
- package/init/docs/_freestruct/inject-footer.html +13 -0
- package/init/docs/_freestruct/inject-header.html +2 -0
- package/init/docs/freestruct.config.yml +14 -0
- package/package.json +56 -4
package/engine/inject.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// engine/inject.js - post-build SEO layer for static doc sites (Phase 1, ESM)
|
|
3
|
+
// Module map (was a 577-LOC single file, PRD 1.1):
|
|
4
|
+
// config.js config discovery + validation
|
|
5
|
+
// scan.js tree walk + page model (slugs, mtimes, noindex detection)
|
|
6
|
+
// seo.js meta/OG/Twitter/JSON-LD injection + late passes
|
|
7
|
+
// xmllike.js escaping + sitemap + robots + RSS
|
|
8
|
+
// notfound.js 404 pipeline
|
|
9
|
+
// linkcheck.js link validation
|
|
10
|
+
// search.js opt-in search.json
|
|
11
|
+
// hooks.js purge hooks
|
|
12
|
+
//
|
|
13
|
+
// CLI (Phase 0/2 contract):
|
|
14
|
+
// --version print the package.json version and exit 0 (works configless;
|
|
15
|
+
// resolved one level up: engine/ is the package root's sibling dir)
|
|
16
|
+
// validate | init [dir] | help subcommands (see the helpers below)
|
|
17
|
+
// --strict opt-in: broken internal links exit 1 instead of
|
|
18
|
+
// shipping green
|
|
19
|
+
// --tolerant opt-out for CI tolerance: fatal config errors log and exit 0
|
|
20
|
+
// (default is FAST_FAIL exit codes, see below)
|
|
21
|
+
// <outputDir> positional output-directory override
|
|
22
|
+
//
|
|
23
|
+
// FAST_FAIL exit codes (PRD Phase 2, config-first diagnostics):
|
|
24
|
+
// 0 success | 1 config fatal | 2 template missing | 3 output dir missing
|
|
25
|
+
//
|
|
26
|
+
// Root resolution (Phase 1.2): the config is discovered by walking UP from
|
|
27
|
+
// the cwd; templates/snippets resolve next to the config file; the output
|
|
28
|
+
// dir defaults to docs/_site next to the config.
|
|
29
|
+
|
|
30
|
+
import fs from 'fs';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
import { pathToFileURL, fileURLToPath } from 'url';
|
|
33
|
+
|
|
34
|
+
import { loadConfig } from './config.js';
|
|
35
|
+
import { getHtmlFiles, buildPages, computeBuildHash, htmlIsNoindex } from './scan.js';
|
|
36
|
+
import { injectPage, addReadingTime, addLastModified, addLazyLoading } from './seo.js';
|
|
37
|
+
import { generateSitemap, generateRobots, generateFeed } from './xmllike.js';
|
|
38
|
+
import { generate404, pipeline404 } from './notfound.js';
|
|
39
|
+
import { checkLinks } from './linkcheck.js';
|
|
40
|
+
import { generateSearchIndex } from './search.js';
|
|
41
|
+
import { runPurgeHooks } from './hooks.js';
|
|
42
|
+
|
|
43
|
+
const FLAG_VALUES = new Set(['--version', '--strict', '--tolerant']);
|
|
44
|
+
const _argv = process.argv.slice(2);
|
|
45
|
+
const _flags = _argv.filter(a => FLAG_VALUES.has(a));
|
|
46
|
+
// Subcommands before the positional output-dir: the first bare word decides
|
|
47
|
+
// what the run is (validate/init/help mirror prestruct's command surface);
|
|
48
|
+
// any other bare word stays the outputDir override it always was.
|
|
49
|
+
const COMMANDS = new Set(['validate', 'init', 'help']);
|
|
50
|
+
const _firstArg = _argv.find(a => !a.startsWith('--'));
|
|
51
|
+
const _positional = _firstArg && !COMMANDS.has(_firstArg) ? _firstArg : undefined;
|
|
52
|
+
|
|
53
|
+
export const EXIT_OK = 0;
|
|
54
|
+
export const EXIT_CONFIG = 1;
|
|
55
|
+
export const EXIT_TEMPLATE = 2;
|
|
56
|
+
export const EXIT_OUTPUT = 3;
|
|
57
|
+
|
|
58
|
+
function fail(message, code = EXIT_CONFIG) {
|
|
59
|
+
console.error('[freestruct] ' + message);
|
|
60
|
+
if (_flags.includes('--tolerant')) {
|
|
61
|
+
console.log('[freestruct] --tolerant: continuing with exit 0 (CI tolerance mode)');
|
|
62
|
+
process.exit(EXIT_OK);
|
|
63
|
+
}
|
|
64
|
+
process.exit(code);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printVersion() {
|
|
68
|
+
// engine/inject.js sits in engine/, one level under the package root -
|
|
69
|
+
// that stays true inside an npm install (node_modules/freestruct/engine/).
|
|
70
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
71
|
+
console.log(pkg.version);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- CLI subcommands (prestruct's init/validate/help surface) ----------
|
|
75
|
+
|
|
76
|
+
const HELP = `
|
|
77
|
+
freestruct -- post-build SEO layer for static doc sites
|
|
78
|
+
|
|
79
|
+
freestruct Run the full post-build pass (config discovered
|
|
80
|
+
by walking up from cwd; output dir defaults to
|
|
81
|
+
docs/_site, or pass one as an argument)
|
|
82
|
+
freestruct validate Load + validate the config; exit 1 on fatal errors
|
|
83
|
+
(no output tree is touched)
|
|
84
|
+
freestruct init [dir] Scaffold the consumer kit (starter config +
|
|
85
|
+
template copies) into <dir>/docs; the default dir
|
|
86
|
+
is the cwd. Existing files are never clobbered.
|
|
87
|
+
freestruct help (--help) This text.
|
|
88
|
+
|
|
89
|
+
Flags: --version | --strict | --tolerant
|
|
90
|
+
Exit codes: 0 ok | 1 config fatal | 2 template missing | 3 output dir missing
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
function printHelp() {
|
|
94
|
+
console.log(HELP);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `freestruct validate`: config-only preflight for CI. Warnings still print
|
|
99
|
+
* (collectConfigDiagnostics runs inside loadConfig); a fatal config error
|
|
100
|
+
* exits EXIT_CONFIG, honoring --tolerant like every other fatal.
|
|
101
|
+
*/
|
|
102
|
+
function cmdValidate() {
|
|
103
|
+
let loaded;
|
|
104
|
+
try {
|
|
105
|
+
loaded = loadConfig(process.cwd());
|
|
106
|
+
} catch (e) {
|
|
107
|
+
fail(e.message, EXIT_CONFIG);
|
|
108
|
+
}
|
|
109
|
+
console.log('[freestruct] config ok: ' + loaded.configPath);
|
|
110
|
+
process.exit(EXIT_OK);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `freestruct init [dir]`: copy the consumer scaffold kit (init/docs: starter
|
|
115
|
+
* config + template copies) into <dir>/docs (default dir: cwd), never
|
|
116
|
+
* clobbering an existing file. Mirrors the manual `cp -r node_modules/
|
|
117
|
+
* freestruct/init/docs .` shape the init kit README describes.
|
|
118
|
+
*/
|
|
119
|
+
function cmdInit(dirArg) {
|
|
120
|
+
const targetRoot = path.resolve(process.cwd(), dirArg || '.');
|
|
121
|
+
const docsDir = path.join(targetRoot, 'docs');
|
|
122
|
+
// engine/inject.js -> package root -> init/docs (files list ships init/)
|
|
123
|
+
const kitDir = path.join(fileURLToPath(new URL('../init/docs', import.meta.url)));
|
|
124
|
+
if (!fs.existsSync(kitDir)) fail('Scaffold kit not found at ' + kitDir, EXIT_TEMPLATE);
|
|
125
|
+
|
|
126
|
+
let created = 0;
|
|
127
|
+
let skipped = 0;
|
|
128
|
+
function copy(src, dest) {
|
|
129
|
+
if (fs.statSync(src).isDirectory()) {
|
|
130
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
131
|
+
for (const entry of fs.readdirSync(src)) copy(path.join(src, entry), path.join(dest, entry));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (fs.existsSync(dest)) { skipped++; console.log(' skip (exists): ' + path.relative(targetRoot, dest)); return; }
|
|
135
|
+
fs.copyFileSync(src, dest);
|
|
136
|
+
created++;
|
|
137
|
+
console.log(' created: ' + path.relative(targetRoot, dest));
|
|
138
|
+
}
|
|
139
|
+
copy(kitDir, docsDir);
|
|
140
|
+
console.log('[freestruct] init: ' + created + ' file(s) created in ' + docsDir + (skipped > 0 ? ', ' + skipped + ' existing file(s) left untouched' : ''));
|
|
141
|
+
if (created === 0) console.log('[freestruct] init: nothing new to scaffold - the kit was already present');
|
|
142
|
+
process.exit(EXIT_OK);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const STRICT = _flags.includes('--strict');
|
|
146
|
+
|
|
147
|
+
export function runInject(options = {}) {
|
|
148
|
+
const cwd = options.cwd || process.cwd();
|
|
149
|
+
console.log('freestruct: Loading config...');
|
|
150
|
+
|
|
151
|
+
let loaded;
|
|
152
|
+
try {
|
|
153
|
+
loaded = loadConfig(cwd);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
fail(e.message, EXIT_CONFIG);
|
|
156
|
+
}
|
|
157
|
+
const { config, configDir } = loaded;
|
|
158
|
+
|
|
159
|
+
// output dir: CLI positional > config.outputDir > docs/_site (configDir already ends in docs/)
|
|
160
|
+
const outputDir = _positional
|
|
161
|
+
? path.resolve(cwd, _positional)
|
|
162
|
+
: path.resolve(configDir, config.outputDir || '_site');
|
|
163
|
+
|
|
164
|
+
if (!fs.existsSync(outputDir)) fail('Output directory not found: ' + outputDir, EXIT_OUTPUT);
|
|
165
|
+
// Template resolution: user-staged first (docs/_freestruct next to the
|
|
166
|
+
// config), then the PACKAGE-shipped copy (npm install ships engine/_freestruct
|
|
167
|
+
// - repo-shaped runs always hit the staged one, fresh-machine installs fall
|
|
168
|
+
// back here; caught by the tarball e2e on a fresh install). The battery
|
|
169
|
+
// pins the staged-only exit-2 shape via FREESTRUCT_SKIP_PACKAGE_FALLBACK.
|
|
170
|
+
// engine/_freestruct is a SIBLING of this engine file, hence './', not '../'.
|
|
171
|
+
const packageTemplateDir = path.join(fileURLToPath(new URL('./_freestruct/', import.meta.url)));
|
|
172
|
+
let snippetDir = path.join(configDir, '_freestruct');
|
|
173
|
+
if (!fs.existsSync(path.join(snippetDir, 'inject-brand.html')) &&
|
|
174
|
+
process.env.FREESTRUCT_SKIP_PACKAGE_FALLBACK !== '1' &&
|
|
175
|
+
fs.existsSync(path.join(packageTemplateDir, 'inject-brand.html'))) {
|
|
176
|
+
snippetDir = packageTemplateDir;
|
|
177
|
+
}
|
|
178
|
+
const templatePath = path.join(snippetDir, 'inject-brand.html');
|
|
179
|
+
if (!fs.existsSync(templatePath)) {
|
|
180
|
+
fail('Template not found: ' + templatePath + ' (shipped copy also missing at ' + packageTemplateDir + ')', EXIT_TEMPLATE);
|
|
181
|
+
}
|
|
182
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
183
|
+
|
|
184
|
+
const htmlFiles = getHtmlFiles(outputDir);
|
|
185
|
+
console.log('Found ' + htmlFiles.length + ' HTML files');
|
|
186
|
+
|
|
187
|
+
// Build hash is content-derived (see scan.js computeBuildHash): the pages
|
|
188
|
+
// snapshot must exist before it can be computed, and empty trees hash the
|
|
189
|
+
// config alone.
|
|
190
|
+
const pagesForHash = htmlFiles.length > 0 ? buildPages(htmlFiles, outputDir) : [];
|
|
191
|
+
const buildHash = computeBuildHash(config, pagesForHash);
|
|
192
|
+
console.log('Build: ' + buildHash);
|
|
193
|
+
|
|
194
|
+
if (htmlFiles.length > 0) {
|
|
195
|
+
const pages = pagesForHash;
|
|
196
|
+
const sitePages = pages.filter(p => !p.is404);
|
|
197
|
+
|
|
198
|
+
// SEO pipeline over every regular page
|
|
199
|
+
const injectCtx = { config, template, buildHash, snippetDir };
|
|
200
|
+
for (const page of sitePages) injectPage(page, injectCtx);
|
|
201
|
+
|
|
202
|
+
// 404 pipeline: generated when absent, user-supplied folded into the
|
|
203
|
+
// same full treatment (Phase 1.4). Runs after regular pages so a 404
|
|
204
|
+
// already present in the tree is not double-processed above.
|
|
205
|
+
const processed404s = [];
|
|
206
|
+
if (config.generate404 !== false) {
|
|
207
|
+
let page404 = pages.find(p => p.is404);
|
|
208
|
+
if (!page404) {
|
|
209
|
+
const file404 = generate404(config, outputDir, buildHash, template, snippetDir);
|
|
210
|
+
const html404 = fs.readFileSync(file404, 'utf8');
|
|
211
|
+
page404 = {
|
|
212
|
+
file: file404,
|
|
213
|
+
rel: '404.html',
|
|
214
|
+
slug: '/404',
|
|
215
|
+
is404: true,
|
|
216
|
+
html: html404,
|
|
217
|
+
mtime: new Date(),
|
|
218
|
+
title: html404.match(/<title>([^<]+)<\/title>/i)?.[1]?.split(' | ')[0] || null,
|
|
219
|
+
description: html404.match(/<meta name="description" content="([^"]+)"/i)?.[1] || null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
processed404s.push(pipeline404(page404, injectCtx));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Late passes over every processed page, 404s included (Phase 1.4)
|
|
226
|
+
const latePages = [...sitePages, ...processed404s];
|
|
227
|
+
if (config.readingTime !== false) for (const page of latePages) addReadingTime(page.file);
|
|
228
|
+
if (config.lastModified !== false) for (const page of latePages) addLastModified(page.file);
|
|
229
|
+
if (config.lazyLoad !== false) for (const page of latePages) addLazyLoading(page.file);
|
|
230
|
+
|
|
231
|
+
// noindex is honored by default (owner decision, issue #5):
|
|
232
|
+
// noindex pages never ship into sitemap/feed/search
|
|
233
|
+
const indexablePages = pages.filter(p => !p.is404 && !htmlIsNoindex(p.html));
|
|
234
|
+
|
|
235
|
+
if (config.generateSitemap !== false) generateSitemap(indexablePages, config, outputDir);
|
|
236
|
+
if (config.generateRobots !== false) generateRobots(config, outputDir);
|
|
237
|
+
if (config.generateFeed !== false) generateFeed(indexablePages, config, outputDir);
|
|
238
|
+
|
|
239
|
+
// link check over regular pages (404 targets excluded by design)
|
|
240
|
+
const brokenLinks = config.linkCheck !== false ? checkLinks(sitePages.map(p => p.file), outputDir) : [];
|
|
241
|
+
|
|
242
|
+
// opt-in search index (owner decision: search.json is opt-in as of Phase 2)
|
|
243
|
+
if (config.searchIndex === true) generateSearchIndex(indexablePages, config, outputDir);
|
|
244
|
+
|
|
245
|
+
// --strict: report-only classes become build failures (Phase 0 contract)
|
|
246
|
+
if (STRICT) {
|
|
247
|
+
const problems = [];
|
|
248
|
+
if (brokenLinks.length > 0) {
|
|
249
|
+
problems.push(brokenLinks.length + ' broken internal link(s)');
|
|
250
|
+
}
|
|
251
|
+
// noindex cannot leak into generated artifacts anymore (filtered above);
|
|
252
|
+
// the gate stays as a defensive tripwire for future artifact changes.
|
|
253
|
+
if (problems.length > 0) {
|
|
254
|
+
console.log('[freestruct] strict mode: build failed:');
|
|
255
|
+
for (const problem of problems) console.log(' - ' + problem);
|
|
256
|
+
for (const b of brokenLinks) console.log(' BROKEN: ' + b.file + ' -> ' + b.link);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// purge hooks, if configured (gated by runHooks)
|
|
263
|
+
if (config.cacheBusting?.purge) runPurgeHooks(config, buildHash, outputDir);
|
|
264
|
+
|
|
265
|
+
console.log('freestruct: Done (' + buildHash + ')');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Module-level command dispatch: the bin shim IMPORTS runInject() rather
|
|
269
|
+
// than invoking this file as main, so commands must dispatch at import time
|
|
270
|
+
// (the pre-change --version module-level check had the same shape). Library
|
|
271
|
+
// importers pass no such argv and simply skip all of these.
|
|
272
|
+
if (_flags.includes('--version')) { printVersion(); process.exit(0); }
|
|
273
|
+
// `help` is the subcommand; --help is the flag alias (both print the same text)
|
|
274
|
+
if (_firstArg === 'help' || _argv.includes('--help')) { printHelp(); process.exit(EXIT_OK); }
|
|
275
|
+
if (_firstArg === 'validate') cmdValidate();
|
|
276
|
+
if (_firstArg === 'init') cmdInit(_argv[_argv.indexOf('init') + 1]);
|
|
277
|
+
|
|
278
|
+
// Main-module detection: npm's .bin shim is a symlink, but import.meta.url
|
|
279
|
+
// resolves the target's real path. Comparing only the raw argv[1] made real
|
|
280
|
+
// installs run silently (exit 0, no work) - caught by the tarball e2e. Only
|
|
281
|
+
// the DEFAULT full-pass run needs the main-module gate.
|
|
282
|
+
const argvReal = process.argv[1]
|
|
283
|
+
? (() => { try { return fs.realpathSync(process.argv[1]); } catch (e) { return process.argv[1]; } })()
|
|
284
|
+
: '';
|
|
285
|
+
const moduleReal = fileURLToPath(import.meta.url);
|
|
286
|
+
const invokedAsMain = argvReal === moduleReal || import.meta.url === pathToFileURL(process.argv[1] || '').href;
|
|
287
|
+
if (invokedAsMain) runInject();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// engine/linkcheck.js - internal link validation (Phase 1.1 split)
|
|
2
|
+
// Report-only by default; --strict (see bin) consumes the returned broken list.
|
|
3
|
+
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check internal links across every page. Returns [{file, link}] for broken
|
|
9
|
+
* targets and prints the human-readable report (same output as 0.4.x).
|
|
10
|
+
*/
|
|
11
|
+
export function checkLinks(files, outputDir) {
|
|
12
|
+
const broken = [];
|
|
13
|
+
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
const html = fs.readFileSync(file, 'utf8');
|
|
16
|
+
const baseDir = path.dirname(file);
|
|
17
|
+
|
|
18
|
+
const linkRegex = /href="([^"#]+)(?:#[^"]*)?"/g;
|
|
19
|
+
let match;
|
|
20
|
+
while ((match = linkRegex.exec(html)) !== null) {
|
|
21
|
+
const link = match[1];
|
|
22
|
+
|
|
23
|
+
// Skip external, mailto, tel
|
|
24
|
+
if (link.startsWith('http') || link.startsWith('mailto:') || link.startsWith('tel:') || link.startsWith('//')) continue;
|
|
25
|
+
|
|
26
|
+
const absPath = path.join(baseDir, link);
|
|
27
|
+
const normalized = path.normalize(absPath);
|
|
28
|
+
|
|
29
|
+
let checkPath = normalized;
|
|
30
|
+
if (fs.existsSync(path.join(normalized, 'index.html'))) {
|
|
31
|
+
checkPath = path.join(normalized, 'index.html');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (normalized.endsWith('.html') && !fs.existsSync(normalized)) {
|
|
35
|
+
broken.push({ file: path.relative(outputDir, file), link });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (broken.length > 0) {
|
|
41
|
+
console.log('Link check: ' + broken.length + ' broken links found');
|
|
42
|
+
for (const b of broken.slice(0, 10)) {
|
|
43
|
+
console.log(' BROKEN: ' + b.file + ' -> ' + b.link);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
console.log('Link check: All links OK');
|
|
47
|
+
}
|
|
48
|
+
return broken;
|
|
49
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// engine/notfound.js - the 404 pipeline (Phase 1.4)
|
|
2
|
+
// FIX (freestruct issue #11): generated AND custom 404 pages now get the full
|
|
3
|
+
// SEO pipeline via the same injectPage call every normal page uses, plus a
|
|
4
|
+
// robots noindex meta. The CHANGELOG's long-claimed noindex finally ships.
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { injectPage } from './seo.js';
|
|
9
|
+
import { pageUrlFor } from './urls.js';
|
|
10
|
+
import { stripRobotsMeta } from './scan.js';
|
|
11
|
+
|
|
12
|
+
/** Generate the fallback 404 page (search form + links), then run it through the SEO pipeline. */
|
|
13
|
+
export function generate404(config, outputDir, buildHash, template, snippetDir) {
|
|
14
|
+
const file = path.join(outputDir, '404.html');
|
|
15
|
+
// Links follow the pageUrlFor contract (basePath-aware) - raw site.url
|
|
16
|
+
// guesses like '/docs/' point at 404s on sub-path installs (#3 class).
|
|
17
|
+
const siteName = config.site.name || 'Site';
|
|
18
|
+
const homeUrl = pageUrlFor(config, '/');
|
|
19
|
+
const notFound = `<!DOCTYPE html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="UTF-8">
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
24
|
+
<title>404 - Page Not Found | ${siteName}</title>
|
|
25
|
+
<style>
|
|
26
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; text-align: center; }
|
|
27
|
+
h1 { font-size: 4rem; margin: 0; color: #333; }
|
|
28
|
+
p { font-size: 1.2rem; color: #666; margin: 20px 0; }
|
|
29
|
+
.search { margin: 30px 0; }
|
|
30
|
+
.search input { width: 70%; padding: 12px; font-size: 1rem; border: 1px solid #ddd; border-radius: 4px; }
|
|
31
|
+
.search button { padding: 12px 20px; font-size: 1rem; background: #333; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
|
|
32
|
+
.links { margin-top: 40px; }
|
|
33
|
+
.links a { color: #0066cc; margin: 0 15px; }
|
|
34
|
+
</style>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<h1>404</h1>
|
|
38
|
+
<p>Page not found</p>
|
|
39
|
+
<p>The page you're looking for doesn't exist or was moved.</p>
|
|
40
|
+
<div class="search">
|
|
41
|
+
<form action="${homeUrl}" method="get">
|
|
42
|
+
<input type="text" name="s" placeholder="Search docs...">
|
|
43
|
+
<button type="submit">Search</button>
|
|
44
|
+
</form>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="links">
|
|
47
|
+
<a href="${homeUrl}">Home</a>
|
|
48
|
+
</div>
|
|
49
|
+
</body>
|
|
50
|
+
</html>`;
|
|
51
|
+
|
|
52
|
+
fs.writeFileSync(file, notFound);
|
|
53
|
+
console.log('404.html generated');
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bring any 404 page (generated or user-supplied) into the full pipeline:
|
|
59
|
+
* injectPage for SEO + an explicit noindex robots meta. Returns the page
|
|
60
|
+
* model so the orchestrator's late passes treat it like every other page.
|
|
61
|
+
*/
|
|
62
|
+
export function pipeline404(page, context) {
|
|
63
|
+
let html = fs.readFileSync(page.file, 'utf8');
|
|
64
|
+
// drop any pre-existing robots meta so ours is the single source of truth
|
|
65
|
+
// (scan.js's stripper exists for exactly this - one regex, one owner)
|
|
66
|
+
html = stripRobotsMeta(html);
|
|
67
|
+
html = html.replace(/<\/head>/i, '<meta name="robots" content="noindex, nofollow">\n</head>');
|
|
68
|
+
fs.writeFileSync(page.file, html);
|
|
69
|
+
page.html = html;
|
|
70
|
+
|
|
71
|
+
injectPage(page, context);
|
|
72
|
+
return page;
|
|
73
|
+
}
|
package/engine/scan.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// engine/scan.js - output tree walk + page model (Phase 1.1)
|
|
2
|
+
// Every page freestruct touches is modeled once here: file path, alias-relative
|
|
3
|
+
// slug, 404 flag, and the pre-injection HTML snapshot the SEO gate needs.
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
|
|
9
|
+
/** Recursively list HTML files under dir (exists-checked). */
|
|
10
|
+
export function getHtmlFiles(dir) {
|
|
11
|
+
const files = [];
|
|
12
|
+
if (!fs.existsSync(dir)) return files;
|
|
13
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
const fp = path.join(dir, item.name);
|
|
15
|
+
if (item.isDirectory()) files.push(...getHtmlFiles(fp));
|
|
16
|
+
else if (item.name.endsWith('.html')) files.push(fp);
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonical slug for a file relative to the output root.
|
|
23
|
+
* index.html -> '/'
|
|
24
|
+
* guide/index.html -> '/guide/'
|
|
25
|
+
* a&b.html -> '/a&b'
|
|
26
|
+
*/
|
|
27
|
+
export function slugFor(rel) {
|
|
28
|
+
let p = String(rel).replace(/\\/g, '/');
|
|
29
|
+
if (p === '/index.html' || p === 'index.html') return '/';
|
|
30
|
+
p = '/' + p;
|
|
31
|
+
p = p
|
|
32
|
+
.replace(/\/index\.html$/, '/')
|
|
33
|
+
.replace(/\.html$/, '');
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 404.html at root or any directory level is the 404 pipeline's business. */
|
|
38
|
+
export function is404File(rel) {
|
|
39
|
+
const p = String(rel).replace(/\\/g, '/');
|
|
40
|
+
return p === '404.html' || p.endsWith('/404.html');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Robots noindex detection on (possibly injected) HTML. */
|
|
44
|
+
export function htmlIsNoindex(html) {
|
|
45
|
+
const m = html.match(/<meta[^>]*name=["']robots["'][^>]*>/i);
|
|
46
|
+
return Boolean(m && /noindex/i.test(m[0]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Strip any existing robots meta (404 pages get ours, un-duplicated). */
|
|
50
|
+
export function stripRobotsMeta(html) {
|
|
51
|
+
return html.replace(/<meta[^>]*name=["']robots["'][^>]*>/i, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extract SEO candidates from raw page HTML. (kept from 0.3.x semantics) */
|
|
55
|
+
export function extractTitle(html) {
|
|
56
|
+
const m = html.match(/<meta name="title" content="([^"]+)"/i);
|
|
57
|
+
if (m) return m[1];
|
|
58
|
+
const t = html.match(/<title>([^<]+)<\/title>/i);
|
|
59
|
+
return t ? t[1].split(' | ')[0] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function extractDescription(html) {
|
|
63
|
+
const m = html.match(/<meta name="description" content="([^"]+)"/i);
|
|
64
|
+
return m ? m[1] : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Does the page already import SEO of the given type from SSG/frontmatter?
|
|
69
|
+
* Used by the missing-only injection gate ('description' added in Phase 1: a
|
|
70
|
+
* plain <meta name="description"> counts as covered).
|
|
71
|
+
*/
|
|
72
|
+
export function hasSeoType(html, type) {
|
|
73
|
+
if (type === 'og') {
|
|
74
|
+
return html.includes('property="og:title"') || html.includes('property="og:description"');
|
|
75
|
+
}
|
|
76
|
+
if (type === 'twitter') {
|
|
77
|
+
return html.includes('name="twitter:card"') || html.includes('name="twitter:title"');
|
|
78
|
+
}
|
|
79
|
+
if (type === 'jsonld') {
|
|
80
|
+
return html.includes('application/ld+json');
|
|
81
|
+
}
|
|
82
|
+
if (type === 'canonical') {
|
|
83
|
+
return html.includes('rel="canonical"');
|
|
84
|
+
}
|
|
85
|
+
if (type === 'description') {
|
|
86
|
+
return /<meta[^>]*name=["']description["']/.test(html);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Snapshot every page once, before freestruct writes anything.
|
|
93
|
+
* mtime is captured HERE so sitemap lastmod / RSS pubDate reflect the last
|
|
94
|
+
* real content change (the SSG build), not freestruct's own decoration writes.
|
|
95
|
+
*/
|
|
96
|
+
export function buildPages(files, outputDir) {
|
|
97
|
+
const pages = [];
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const rel = path.relative(outputDir, file);
|
|
100
|
+
const html = fs.readFileSync(file, 'utf8');
|
|
101
|
+
pages.push({
|
|
102
|
+
file,
|
|
103
|
+
rel,
|
|
104
|
+
slug: slugFor(rel),
|
|
105
|
+
is404: is404File(rel),
|
|
106
|
+
html,
|
|
107
|
+
mtime: fs.statSync(file).mtime,
|
|
108
|
+
title: extractTitle(html),
|
|
109
|
+
description: extractDescription(html),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return pages;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Deterministic content hash (ported principle from prestruct's deploy-id
|
|
117
|
+
* audit: a timestamp-seeded hash churns ?v= asset params and CDN caches on
|
|
118
|
+
* every run even when nothing changed). Derived from config + the sorted
|
|
119
|
+
* pre-injection page content, so identical content always yields an
|
|
120
|
+
* identical hash regardless of build time or machine.
|
|
121
|
+
*
|
|
122
|
+
* Everything this tool itself rewrites between runs is stripped or excluded
|
|
123
|
+
* before hashing: the regenerated last-modified/reading-time/freestruct-build
|
|
124
|
+
* metas, the previous ?v= bust params, and the whole head region after the
|
|
125
|
+
* freestruct marker (that region holds the custom header snippet, whose
|
|
126
|
+
* substituted {{buildHash}} would make the hash self-referential).
|
|
127
|
+
* blurred lines, accepted consequences:
|
|
128
|
+
* - robots metas are excluded too: the 404 pipeline strip-and-reappears it
|
|
129
|
+
* at a different position each run (404 is tool-owned anyway); a noindex
|
|
130
|
+
* toggle on a user page will therefore not change the bust hash, and the
|
|
131
|
+
* page is not crawled either way.
|
|
132
|
+
* - a body/footer snippet that embeds {{buildHash}} self-references; that
|
|
133
|
+
* is documented as a no-go, not hacked around in the regex.
|
|
134
|
+
*/
|
|
135
|
+
export function computeBuildHash(config, pages) {
|
|
136
|
+
const h = crypto.createHash('sha1');
|
|
137
|
+
h.update(JSON.stringify(config || {}));
|
|
138
|
+
// 404 pages sit outside the hash entirely: freestruct generates the missing
|
|
139
|
+
// one mid-run (so run 1 and run 2 would otherwise disagree on page count),
|
|
140
|
+
// and a noindex 404 is never crawled regardless.
|
|
141
|
+
const stable = (html) => html
|
|
142
|
+
.replace(/<meta[^>]*name="(freestruct-build|last-modified|reading-time|robots)"[^>]*>/gi, '')
|
|
143
|
+
.replace(/<!-- freestruct -->[\s\S]*?(?=<\/head>)/i, '')
|
|
144
|
+
.replace(/\?v=[0-9a-f]{8}/g, '')
|
|
145
|
+
.replace(/ loading="lazy"/g, '');
|
|
146
|
+
for (const page of pages.filter(p => !p.is404).sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))) {
|
|
147
|
+
h.update(page.rel);
|
|
148
|
+
h.update(stable(page.html));
|
|
149
|
+
}
|
|
150
|
+
return h.digest('hex').slice(0, 8);
|
|
151
|
+
}
|
package/engine/search.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// engine/search.js - search.json generation (Phase 1.1 split)
|
|
2
|
+
// OPT-IN feature (owner decision): only generated when config.searchIndex is
|
|
3
|
+
// explicitly true. FIXES from the 0.4.x state:
|
|
4
|
+
// - noindex pages are excluded (owner: noindex honored by default, issue #5)
|
|
5
|
+
// - URLs come from scan.js slugs, so 'noindex.html' no longer becomes /no
|
|
6
|
+
// and the root page is indexed under '/', not '/index'
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { pageUrlFor } from './urls.js';
|
|
11
|
+
|
|
12
|
+
export function generateSearchIndex(pages, config, outputDir) {
|
|
13
|
+
const index = [];
|
|
14
|
+
|
|
15
|
+
for (const page of pages) {
|
|
16
|
+
if (page.slug === '/404') continue;
|
|
17
|
+
// Fresh raw HTML for the content pass (pre-injection snapshot excludes
|
|
18
|
+
// what freestruct itself writes, but post-injection is what a browser
|
|
19
|
+
// would search; raw file content at this point is the injected page).
|
|
20
|
+
const html = fs.readFileSync(page.file, 'utf8');
|
|
21
|
+
|
|
22
|
+
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
|
23
|
+
const descMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
|
|
24
|
+
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
|
25
|
+
|
|
26
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
27
|
+
const content = bodyMatch
|
|
28
|
+
? bodyMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
29
|
+
: '';
|
|
30
|
+
|
|
31
|
+
const title = titleMatch ? titleMatch[1].split(' | ')[0] : path.basename(page.file, '.html');
|
|
32
|
+
const description = descMatch ? descMatch[1] : '';
|
|
33
|
+
const heading = h1Match ? h1Match[1] : title;
|
|
34
|
+
const url = pageUrlFor(config, page.slug);
|
|
35
|
+
|
|
36
|
+
index.push({ title, heading, description, content, url });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fs.writeFileSync(path.join(outputDir, 'search.json'), JSON.stringify(index, null, 2));
|
|
40
|
+
console.log('search.json generated (' + index.length + ' pages)');
|
|
41
|
+
}
|