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.
@@ -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
+ }
@@ -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
+ }