prestruct 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.
@@ -0,0 +1,530 @@
1
+ // scripts/audit.js - the `prestruct audit` post-build verifier
2
+ //
3
+ // Ported and remapped from freestruct's `audit` subcommand (their audit.js is
4
+ // itself a port of the prestruct#34 donated script -- the family keeps ONE
5
+ // audit contract). Remaps onto OUR contract, which is not freestruct's:
6
+ // - config is ssr.config.js: siteUrl (not site.url), ogImage (string OR
7
+ // the structured { url, width, height, alt, type } object form)
8
+ // - prestruct ships no twitter handle config, so twitter:site/creator are
9
+ // never graded at all (the engine never emits them; grading their
10
+ // absence would fail contract-abiding output)
11
+ // - robots.txt grading only when config.robots is truthy: without it the
12
+ // author's own public/robots.txt is engine-untouchable (opt-in preset
13
+ // contract), so a missing pointer there is the author's call, not a FAIL
14
+ // - sitemap.xml is engine-authored on every prerender (missing = a FAIL)
15
+ //
16
+ // FAIL class (exit 1): the contract core -- missing title/description,
17
+ // relative canonical, duplicate head tags, missing og/twitter core, the SVG
18
+ // og:image trap, unparseable JSON-LD, the 404 noindex contract, missing
19
+ // sitemap, robots missing its pointer (when engine-authored).
20
+ // WARN class (report-grade): length bands, extended og/twitter set, missing
21
+ // og:image companions (the object form supplies them), disk-missing
22
+ // og:image/JSON-LD refs, h1 hygiene, no JSON-LD, absent sitemap lastmod.
23
+ //
24
+ // Exit decisions belong to the caller (bin/cli.mjs): auditOutput NEVER exits.
25
+ // The shared audit shape (fails/warns/pages/sitemapLocs result, --json
26
+ // digest, capture mode) stays freestruct-compatible so shared family
27
+ // tooling keeps working against either engine.
28
+
29
+ import { readFileSync, existsSync, readdirSync, statSync, realpathSync } from 'fs'
30
+ import path from 'path'
31
+ import { fileURLToPath, pathToFileURL } from 'url'
32
+
33
+ // FAIL class = the tags prestruct's own contract writes into every prerendered
34
+ // head (shell injection + per-route meta). The extended sets ride WARN.
35
+ const OG_CORE = ['og:title', 'og:description']
36
+ const OG_SET = ['og:url', 'og:type', 'og:site_name', 'og:locale']
37
+ const TW_CORE = ['twitter:card', 'twitter:title']
38
+ const TW_SET = ['twitter:description', 'twitter:image']
39
+ // LocalBusiness recommended fields per the schema.org contract.
40
+ const LB_RECOMMENDED = ['name', 'url', 'image', 'logo', 'telephone', 'priceRange', 'openingHoursSpecification', 'geo', 'sameAs']
41
+
42
+ // ---------- scans ----------
43
+
44
+ /** Attribute-order/whitespace tolerant meta extraction: the key attr may sit
45
+ * after other attributes and any amount of whitespace. The old `[^>]`
46
+ * (exactly one char) matched only the engine's own single-space output, so a
47
+ * hand-formatted shell (`<meta property=`, or an attribute before the key)
48
+ * graded as a MISSING tag -- a false FAIL that fails the build. */
49
+ function grabMeta(head, attrHasKey, name) {
50
+ // `\s` (not `\b`) before the key: an attribute is whitespace-delimited, so
51
+ // this matches `<meta property=` and `<meta property=` but never a
52
+ // hyphenated sibling like `data-name=` (where `\b` would falsely match).
53
+ const keyRe = new RegExp(`<meta\\b[^>]*\\s${attrHasKey}\\s*=\\s*["']${name}["'][^>]*>`, 'i')
54
+ const tag = head.match(keyRe)
55
+ if (!tag) return null
56
+ const content = tag[0].match(/content\s*=\s*["']([^"']*)["']/i)
57
+ return content ? content[1] : null
58
+ }
59
+
60
+ function grabCanonical(head) {
61
+ const link = head.match(/<link[^>]+rel=["']canonical["'][^>]*>/i)
62
+ || head.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']canonical["'][^>]*>/i)
63
+ if (!link) return null
64
+ const href = link[0].match(/href=["']([^"']+)["']/i)
65
+ return href ? href[1] : null
66
+ }
67
+
68
+ /** Count every occurrence of a head shape - the duplicate-class probe. */
69
+ function countHead(head, re) {
70
+ return (head.match(re) || []).length
71
+ }
72
+
73
+ function walkHtml(dir) {
74
+ let out = []
75
+ for (const name of readdirSync(dir)) {
76
+ const p = path.join(dir, name)
77
+ if (statSync(p).isDirectory()) out = out.concat(walkHtml(p))
78
+ else if (/\.html?$/i.test(name)) out.push(p)
79
+ }
80
+ return out
81
+ }
82
+
83
+ function stripTags(s) {
84
+ return String(s).replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').trim()
85
+ }
86
+
87
+ /** Head-referenced URL -> on-disk path under the output dir, or null. */
88
+ function urlToDisk(urlValue, { outputDir, config }) {
89
+ if (!urlValue) return null
90
+ let pathName = urlValue.split(/[?#]/)[0]
91
+ if (/^https?:\/\//i.test(pathName)) {
92
+ try {
93
+ const u = new URL(pathName)
94
+ const site = config?.siteUrl ? new URL(config.siteUrl) : null
95
+ // Only site-local files can exist in the output tree.
96
+ if (site && u.host !== site.host) return null
97
+ pathName = u.pathname + u.search
98
+ } catch (e) { return null }
99
+ } else if (/^\/\//.test(pathName)) {
100
+ return null
101
+ }
102
+ if (pathName.startsWith('/')) {
103
+ const root = path.resolve(outputDir)
104
+ const target = path.resolve(root, pathName.replace(/^\//, ''))
105
+ // Containment guard: a crafted og:image/JSON-LD URL with `../../`
106
+ // segments (`/../../../etc/passwd`) resolved OUTSIDE the output tree, so
107
+ // the exists-on-disk probe read arbitrary host-path existence. Confine
108
+ // every resolved path to the output root; anything that escapes is not a
109
+ // site-local file.
110
+ if (target !== root && !target.startsWith(root + path.sep)) return null
111
+ return target
112
+ }
113
+ return null
114
+ }
115
+
116
+ // ---------- the audit ----------
117
+
118
+ /**
119
+ * Audit a baked output directory. Returns { fails, warns, pages, sitemapLocs }.
120
+ * Prints a human report by default, or a JSON digest with `json: true`;
121
+ * `capture: true` skips printing and hands the caller the raw result.
122
+ * NEVER exits: the caller owns the exit code (0 clean / 1 findings).
123
+ */
124
+ export function auditOutput({ outputDir, config, json = false, capture = false }) {
125
+ const fails = []
126
+ const warns = []
127
+ const fail = (page, msg) => fails.push({ page, msg })
128
+ const warn = (page, msg) => warns.push({ page, msg })
129
+
130
+ // File-at-path hardening (freestruct Pass 26.13 shape): a file at the
131
+ // outputDir path passes existsSync, and walkHtml's readdirSync would
132
+ // arrive here uncaught.
133
+ if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) {
134
+ if (json) console.log(JSON.stringify({ ok: false, error: 'output dir not found: ' + outputDir }))
135
+ else console.error('[prestruct] audit: no "' + outputDir + '" directory - run the build first.')
136
+ return { fails: [{ page: 'outputDir', msg: 'missing' }], warns, pages: 0, sitemapLocs: 0 }
137
+ }
138
+
139
+ const pages = walkHtml(outputDir)
140
+ let sitemapLocs = 0
141
+
142
+ for (const filePath of pages) {
143
+ let rel
144
+ try { rel = path.relative(outputDir, filePath) } catch (e) { rel = filePath }
145
+ const html = readFileSync(filePath, 'utf8')
146
+ const head = html.split('</head>')[0]
147
+ const label = rel.replace(/\\/g, '/')
148
+
149
+ // 404 contract: noindex (and it must not be rescued by JS: our 404 is
150
+ // pure static HTML, but the noindex meta is what the audit pins).
151
+ if (/(^|\/)404\.html?$/i.test(label)) {
152
+ const robots = grabMeta(head, 'name', 'robots') || ''
153
+ if (!/noindex/i.test(robots)) fail(label, '404 page missing its noindex robots directive')
154
+ continue
155
+ }
156
+
157
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
158
+ if (!titleMatch || !titleMatch[1].trim()) fail(label, 'missing <title>')
159
+ else {
160
+ const t = stripTags(titleMatch[1]).length
161
+ if (t > 65) warn(label, 'title ' + t + ' chars (> 65, may truncate)')
162
+ if (t < 10) warn(label, 'title ' + t + ' chars (suspiciously short)')
163
+ }
164
+
165
+ const desc = grabMeta(head, 'name', 'description')
166
+ if (!desc) fail(label, 'missing meta description')
167
+ else if (desc.length < 70 || desc.length > 165) {
168
+ warn(label, 'description ' + desc.length + ' chars (outside 70-165, may truncate)')
169
+ }
170
+
171
+ const canonical = grabCanonical(head)
172
+ if (!canonical || canonical === '') fail(label, 'missing canonical link')
173
+ else if (!/^https?:\/\//i.test(canonical)) fail(label, 'canonical not absolute: ' + canonical)
174
+
175
+ // Duplicate tags ride the FAIL class: missing-only injection never adds
176
+ // a tag the page already carries, so any duplicate in a baked head is a
177
+ // page/edit gone wrong (attribute-order tolerant, same as the missing
178
+ // checks). Guards the rebuild path: inject-brand strips its previous
179
+ // block, so a surviving duplicate is exactly what the guard exists for.
180
+ for (const dup of [
181
+ ['duplicate canonical link (a page ships exactly one)', /<link[^>]+rel=["']canonical["']/gi],
182
+ ['duplicate meta description', /<meta[^>]+name=["']description["']/gi],
183
+ ['multiple <title> elements', /<title[^>]*>/gi],
184
+ ['duplicate og:title', /<meta[^>]+property=["']og:title["']/gi],
185
+ ]) {
186
+ if (countHead(head, dup[1]) > 1) fail(label, dup[0])
187
+ }
188
+
189
+ for (const prop of OG_CORE) {
190
+ if (!grabMeta(head, 'property', prop)) fail(label, 'missing ' + prop)
191
+ }
192
+ for (const prop of OG_SET) {
193
+ if (!grabMeta(head, 'property', prop)) {
194
+ warn(label, prop + ' absent (the engine ships the full set; partial sets are a hand-edited shell?)')
195
+ }
196
+ }
197
+ const ogImage = grabMeta(head, 'property', 'og:image')
198
+ if (ogImage && /\.svg(\?|#|$)/i.test(ogImage)) {
199
+ fail(label, 'og:image is SVG (' + ogImage + ') - most social scrapers cannot render it; ship a raster PNG/JPG (tools/render-og.mjs style)')
200
+ }
201
+ if (ogImage && !grabMeta(head, 'property', 'og:image:width')) {
202
+ warn(label, 'og:image:width/height/alt absent (the ogImage object form supplies them)')
203
+ }
204
+ if (ogImage && urlToDisk(ogImage, { outputDir, config }) && !existsSync(urlToDisk(ogImage, { outputDir, config }))) {
205
+ // Report-grade on purpose: an og image can live on a same-host CDN or
206
+ // be emitted by a later step, so a hard FAIL would punish valid setups;
207
+ // the JSON digest carries it for teams who harden it in CI.
208
+ warn(label, 'og:image points at ' + ogImage + ' which does not exist on disk')
209
+ }
210
+ if (ogImage && !grabMeta(head, 'name', 'twitter:image')) {
211
+ warn(label, 'og:image present but twitter:image absent (the shell injects both together) - hand-edited?')
212
+ }
213
+
214
+ for (const name of TW_CORE) {
215
+ if (!grabMeta(head, 'name', name)) fail(label, 'missing ' + name)
216
+ }
217
+ for (const name of TW_SET) {
218
+ if (!grabMeta(head, 'name', name)) {
219
+ warn(label, name + ' absent (twitter:image rides og:image; twitter:description rides the description)')
220
+ }
221
+ }
222
+
223
+ const ldBlocks = [...html.matchAll(/<script type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)]
224
+ if (!ldBlocks.length) warn(label, 'no JSON-LD structured data (faqs or buildJsonLd supply it)')
225
+ for (const block of ldBlocks) {
226
+ let parsed
227
+ try { parsed = JSON.parse(block[1]) } catch (e) {
228
+ fail(label, 'invalid JSON-LD: ' + e.message)
229
+ continue
230
+ }
231
+ const nodes = Array.isArray(parsed) ? parsed : (parsed['@graph'] ? parsed['@graph'] : [parsed])
232
+ for (const node of nodes) {
233
+ if (!node || typeof node !== 'object') continue
234
+ if (!node['@type']) { warn(label, 'JSON-LD node without @type'); continue }
235
+ const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']]
236
+ if (types.includes('LocalBusiness')) {
237
+ const missing = LB_RECOMMENDED.filter(f => !(f in node))
238
+ if (missing.length) warn(label, 'LocalBusiness missing recommended: ' + missing.join(', '))
239
+ }
240
+ // schema-carried logo/image files: same exists-on-disk check as og:image
241
+ for (const key of ['logo', 'image']) {
242
+ const v = node[key]
243
+ const target = urlToDisk(typeof v === 'string' ? v : null, { outputDir, config })
244
+ if (target && !existsSync(target)) {
245
+ warn(label, 'JSON-LD ' + key + ' points at ' + v + ' which does not exist on disk')
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ const h1s = [...html.matchAll(/<h1[^>]*>([\s\S]*?)<\/h1>/gi)]
252
+ const h1Texts = h1s.map(m => stripTags(m[1])).filter(Boolean)
253
+ if (!h1Texts.length) warn(label, 'no non-empty <h1>')
254
+ else if (h1Texts.length > 1) warn(label, h1Texts.length + ' <h1> elements (prefer one)')
255
+ }
256
+
257
+ // ---------- site-level artifacts ----------
258
+
259
+ const sitemapPath = path.join(outputDir, 'sitemap.xml')
260
+ if (!existsSync(sitemapPath)) {
261
+ fail('sitemap.xml', 'missing (prerender writes it on every run)')
262
+ } else {
263
+ const s = readFileSync(sitemapPath, 'utf8')
264
+ sitemapLocs = (s.match(/<loc>/g) || []).length
265
+ if (sitemapLocs === 0) fail('sitemap.xml', 'no <loc> entries')
266
+ if (!s.includes('<lastmod>')) warn('sitemap.xml', 'no <lastmod> dates (engine runs always add them; hand-stripped?)')
267
+ }
268
+
269
+ // robots.txt ships only when config.robots is opted in -- the author's own
270
+ // public/robots.txt is never engine-touched, so its shape stays the
271
+ // author's call. Graded only when the engine contract owns the file.
272
+ const robotsPath = path.join(outputDir, 'robots.txt')
273
+ if (config?.robots) {
274
+ if (!existsSync(robotsPath)) {
275
+ fail('robots.txt', 'missing (config.robots is set -- prerender writes it)')
276
+ } else {
277
+ const r = readFileSync(robotsPath, 'utf8')
278
+ if (!/Sitemap:/i.test(r)) fail('robots.txt', 'no Sitemap: pointer')
279
+ }
280
+ }
281
+
282
+ // ---------- report ----------
283
+
284
+ if (capture) {
285
+ return { fails, warns, pages: pages.length, sitemapLocs }
286
+ }
287
+ if (json) {
288
+ console.log(JSON.stringify({ ok: fails.length === 0, pages: pages.length, fails, warns }))
289
+ } else {
290
+ console.log('[prestruct] SEO audit: ' + pages.length + ' baked page(s) checked, sitemap.xml has ' + sitemapLocs + ' <loc> entries')
291
+ for (const f of fails) console.log(' FAIL ' + f.page + ': ' + f.msg)
292
+ for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg)
293
+ if (fails.length === 0) {
294
+ console.log('[prestruct] audit: 0 failures, ' + warns.length + ' warning(s) - clean')
295
+ } else {
296
+ console.log('[prestruct] audit: ' + fails.length + ' failure(s), ' + warns.length + ' warning(s) - exiting 1')
297
+ }
298
+ }
299
+ return { fails, warns, pages: pages.length, sitemapLocs }
300
+ }
301
+
302
+ // ---------- external links (opt-in, --external) ----------
303
+
304
+ const EXTERNAL_LIMIT = 50
305
+
306
+ /** Collect external http(s) href URLs from the baked pages, deduped, with referencing pages. */
307
+ function collectExternalLinks({ outputDir, pages }) {
308
+ const byUrl = new Map()
309
+ for (const filePath of pages) {
310
+ let rel
311
+ try { rel = path.relative(outputDir, filePath).replace(/\\/g, '/') } catch (e) { rel = filePath }
312
+ const html = readFileSync(filePath, 'utf8')
313
+ for (const m of html.matchAll(/href\s*=\s*["'](https?:\/\/[^"'\s]+)["']/gi)) {
314
+ const url = m[1].replace(/&amp;/g, '&')
315
+ if (!byUrl.has(url)) byUrl.set(url, [])
316
+ byUrl.get(url).push(rel)
317
+ }
318
+ }
319
+ return byUrl
320
+ }
321
+
322
+ /** HEAD with a GET retry (some CDNs refuse HEAD); returns the problem string or null. */
323
+ async function headCheck(url) {
324
+ const tryOnce = async (method) => {
325
+ const res = await fetch(url, {
326
+ method,
327
+ redirect: 'follow',
328
+ signal: AbortSignal.timeout(8000),
329
+ headers: { 'user-agent': 'prestruct-audit (+https://github.com/dhaupin/prestruct)' },
330
+ })
331
+ return res.status >= 400 ? 'HTTP ' + res.status + (res.statusText ? ' ' + res.statusText : '') : null
332
+ }
333
+ try {
334
+ return await tryOnce('HEAD')
335
+ } catch (headErr) {
336
+ try {
337
+ return await tryOnce('GET')
338
+ } catch (getErr) {
339
+ const cause = (getErr && getErr.cause && getErr.cause.code) ? getErr.cause.code : getErr.message
340
+ return 'unreachable (' + cause + ')'
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * External-link audit (opt-in): dedupe the tree's external hrefs, HEAD-check
347
+ * up to EXTERNAL_LIMIT unique URLs, report every failure as a WARN row
348
+ * (report-grade by design: bots get 403/429 legitimately). NEVER exits.
349
+ */
350
+ export async function auditExternalLinks({ outputDir, json = false }) {
351
+ const empty = { warns: [], checked: 0 }
352
+ if (!existsSync(outputDir)) return empty
353
+ const pages = walkHtml(outputDir)
354
+ const byUrl = collectExternalLinks({ outputDir, pages })
355
+ if (byUrl.size === 0) return empty
356
+ const capped = [...byUrl.keys()].slice(0, EXTERNAL_LIMIT)
357
+ const results = await Promise.all(capped.map(async (url) => [url, await headCheck(url)]))
358
+ const warns = []
359
+ for (const [url, problem] of results) {
360
+ if (!problem) continue
361
+ const referencing = byUrl.get(url)
362
+ const label = referencing.length > 1
363
+ ? referencing[0] + ' (+' + (referencing.length - 1) + ' more page(s))'
364
+ : referencing[0]
365
+ warns.push({ page: label, msg: 'external link ' + url + ': ' + problem })
366
+ }
367
+ if (byUrl.size > EXTERNAL_LIMIT) {
368
+ warns.push({ page: 'external', msg: 'checked only the first ' + EXTERNAL_LIMIT + ' unique external urls of ' + byUrl.size })
369
+ }
370
+ if (!json && warns.length > 0) {
371
+ console.log('[prestruct] external link check: ' + capped.length + ' unique url(s) probed')
372
+ for (const w of warns) console.log(' WARN ' + w.page + ': ' + w.msg)
373
+ }
374
+ return { warns, checked: capped.length }
375
+ }
376
+
377
+ // ---------- internal links (opt-in, --internal) ----------
378
+
379
+ /**
380
+ * Check internal links across every page. Returns [{file, link}] for broken
381
+ * targets; warns -- internal links to routes that were never prerendered
382
+ * (dynamic routes, islands territory) are a legitimate gap prestruct does
383
+ * not punish hard: report-grade always. Ported from freestruct's linkcheck
384
+ * including their payloads lesson: inline JS template strings
385
+ * ("<a href=" + url + ">") and rouge-fenced / code-payload pseudo-links are
386
+ * NOT routes -- script, style, pre and code payloads are stripped before
387
+ * the href scan (our example is code-heavy: the naive scan lit up).
388
+ */
389
+ export function auditInternalLinks({ outputDir }) {
390
+ const broken = []
391
+ if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) return broken
392
+ const files = walkHtml(outputDir)
393
+
394
+ for (const file of files) {
395
+ const html = readFileSync(file, 'utf8')
396
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
397
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
398
+ .replace(/<pre[\s\S]*?<\/pre>/gi, '')
399
+ .replace(/<code[\s\S]*?<\/code>/gi, '')
400
+ const baseDir = path.dirname(file)
401
+
402
+ const linkRegex = /href="([^"#]+)(?:#[^"]*)?"/g
403
+ let match
404
+ while ((match = linkRegex.exec(html)) !== null) {
405
+ const link = match[1]
406
+
407
+ // Skip external, absolute-protocol, and ANY scheme-shaped target
408
+ // (data:, javascript:, tel:, mailto:, ...): schemes are not routes.
409
+ if (/^[a-z][a-z0-9+.-]*:/i.test(link) || link.startsWith('//')) continue
410
+
411
+ // Root-relative hrefs resolve against the OUTPUT ROOT (path.join
412
+ // APPENDS, never resets -- the whole nav menu would miss otherwise);
413
+ // relative hrefs stay page-relative.
414
+ const absPath = link.startsWith('/')
415
+ ? path.join(outputDir, link)
416
+ : path.join(baseDir, link)
417
+ const normalized = path.normalize(absPath)
418
+
419
+ // Targeted file link: the extension decides the check shape
420
+ // (case-insensitive .html/.htm).
421
+ if (/\.html?$/i.test(normalized)) {
422
+ if (!existsSync(normalized)) {
423
+ broken.push({ file: path.relative(outputDir, file), link })
424
+ }
425
+ continue
426
+ }
427
+
428
+ // Pretty-URL link: resolve like the route model -- directory index
429
+ // first, then the .html/.htm sibling. Asset-like targets (their last
430
+ // segment carries an extension that is not a page route: .css, .js,
431
+ // .png, .json, ...) are the host's business and stay out.
432
+ const lastSegment = normalized.split(path.sep).pop() || ''
433
+ if (lastSegment.includes('.')) continue
434
+
435
+ const dirIndex = existsSync(path.join(normalized, 'index.html'))
436
+ || existsSync(path.join(normalized, 'index.htm'))
437
+ const prettySibling = existsSync(path.join(normalized + '.html'))
438
+ || existsSync(path.join(normalized + '.htm'))
439
+ if (!dirIndex && !prettySibling) {
440
+ broken.push({ file: path.relative(outputDir, file), link })
441
+ }
442
+ }
443
+ }
444
+ return broken
445
+ }
446
+
447
+ // ---------- main-module runner ----------
448
+
449
+ // Scaffolded copies run as `node scripts/audit.js` straight after the build
450
+ // (example/init build scripts; the family audit shape). Module importers skip
451
+ // all of this -- the freestruct bin shim lesson: import.meta.url resolves the
452
+ // symlink's REAL path, so compare realpaths, never raw argv[1] (a naive match
453
+ // made real installs run silently with exit 0 and no work).
454
+ async function runAuditMain() {
455
+ const argv = process.argv.slice(2)
456
+ const knownFlags = new Set(['--json', '--external', '--internal'])
457
+ const dashArgs = argv.filter(a => a.startsWith('--'))
458
+ const unknown = dashArgs.filter(a => !knownFlags.has(a))
459
+ if (unknown.length) {
460
+ console.error(`[prestruct] audit: unknown flag(s): ${unknown.join(' ')} - supported: --json --external --internal`)
461
+ process.exit(1)
462
+ }
463
+ const explicitDir = argv.find(a => !a.startsWith('--'))
464
+
465
+ // Config discovery: walk up from cwd for ssr.config.js. Audit runs
466
+ // configless against any tree (explicitDir positional wins; the config
467
+ // exists check lands in auditOutput).
468
+ let config = null
469
+ let dir = process.cwd()
470
+ while (dir !== path.dirname(dir)) {
471
+ const cfgPath = path.join(dir, 'ssr.config.js')
472
+ if (existsSync(cfgPath)) {
473
+ try { config = (await import(pathToFileURL(cfgPath))).default } catch (e) {
474
+ console.error('[prestruct] audit: ssr.config.js failed to load: ' + e.message)
475
+ }
476
+ break
477
+ }
478
+ dir = path.dirname(dir)
479
+ }
480
+
481
+ const outputDir = explicitDir
482
+ ? path.resolve(process.cwd(), explicitDir)
483
+ : path.join(path.dirname(process.argv[1] || '.'), '..', 'dist')
484
+
485
+ // capture: the runner owns the single JSON line below (combined with the
486
+ // aux probes), so auditOutput must not print its own -- the old json:true
487
+ // emitted two concatenated digests for a plain `audit --json`.
488
+ const result = auditOutput({ outputDir, config, capture: argv.includes('--json') })
489
+
490
+ // --internal: broken internal href rows (report-grade always: routes that
491
+ // were never prerendered are a legitimate dynamic-route gap).
492
+ const internal = argv.includes('--internal') ? auditInternalLinks({ outputDir }) : []
493
+ if (internal.length && !argv.includes('--json')) {
494
+ console.log('[prestruct] internal link check: ' + internal.length + ' broken link(s)')
495
+ for (const b of internal) console.log(' WARN ' + b.file + ' -> ' + b.link)
496
+ }
497
+
498
+ // --external: HEAD->GET probe of up to 50 unique external hrefs (network).
499
+ let external = { warns: [] }
500
+ if (argv.includes('--external')) external = await auditExternalLinks({ outputDir })
501
+
502
+ if (argv.includes('--json')) {
503
+ console.log(JSON.stringify({
504
+ ok: result.fails.length === 0,
505
+ pages: result.pages,
506
+ fails: result.fails,
507
+ warns: [...result.warns, ...internal.map(b => ({ page: b.file, msg: 'broken internal link: ' + b.link })), ...external.warns],
508
+ }))
509
+ } else if (internal.length || external.warns.length) {
510
+ // Aux probes append rows the base report never saw -- the combined
511
+ // summary is worth a line. Pure-local audits skip it: auditOutput's own
512
+ // report already ended in the verdict (one verdict, not two).
513
+ const totalWarns = result.warns.length + internal.length + external.warns.length
514
+ console.log(result.fails.length === 0
515
+ ? '[prestruct] audit: 0 failures, ' + totalWarns + ' warning(s) - clean'
516
+ : '[prestruct] audit: ' + result.fails.length + ' failure(s), ' + totalWarns + ' warning(s) - exiting 1')
517
+ }
518
+ process.exit(result.fails.length > 0 ? 1 : 0)
519
+ }
520
+
521
+ const argvReal = process.argv[1]
522
+ ? (() => { try { return realpathSync(process.argv[1]) } catch (e) { return process.argv[1] } })()
523
+ : ''
524
+ const moduleReal = fileURLToPath(import.meta.url)
525
+ if (argvReal === moduleReal || import.meta.url === pathToFileURL(process.argv[1] || '').href) {
526
+ runAuditMain().catch((e) => {
527
+ console.error('[prestruct] audit: ' + (e && e.message ? e.message : e))
528
+ process.exit(1)
529
+ })
530
+ }
@@ -0,0 +1,93 @@
1
+ // engine/scripts/hooks.js - general postBuild hook contract (prestruct#41,
2
+ // port of the freestruct tag reference; prestruct#21's shape). Replaces the
3
+ // npm-script chain ("prestruct build && node scripts/whatever.mjs") with a
4
+ // first-class seam:
5
+ //
6
+ // // ssr.config.js
7
+ // hooks: { postBuild: ['node scripts/inject-faq-schema.mjs'] }
8
+ // runHooks: true, // opt-in: nothing executes without it
9
+ //
10
+ // Security posture (why the gate): postBuild entries are shell commands
11
+ // sourced from the config file. A malicious or typo'd config must not gain
12
+ // a silent execution path, so nothing runs unless runHooks is explicitly
13
+ // true - logged loudly either way. The build pipeline runs the hooks
14
+ // itself, so `prestruct build` can no longer skip them the way it skipped
15
+ // the npm-script chain.
16
+ //
17
+ // Values ride FUNCTION replacers, never string-form replace strings: an
18
+ // output path carrying $& or $1..9 is replacement SYNTAX in string form
19
+ // and would corrupt the command about to run (the same never-corrupt rule
20
+ // the injection pipeline enforces on head values).
21
+ //
22
+ // This module NEVER exits: it returns the failure list; bin/cli.mjs turns
23
+ // the collection into the build exit.
24
+
25
+ import { execSync } from 'child_process'
26
+
27
+ /** Build a fresh env for hook children: same facts as the placeholders, so
28
+ * a hook script does not do string surgery to learn where the build landed. */
29
+ function hookEnv({ siteUrl, outputDir }) {
30
+ return {
31
+ ...process.env,
32
+ PRESTRUCT_SITE_URL: siteUrl,
33
+ PRESTRUCT_OUTPUT_DIR: outputDir,
34
+ }
35
+ }
36
+
37
+ function substitutePlaceholders(command, { siteUrl, outputDir }) {
38
+ return command
39
+ .replace(/\$\{?SITE_URL\}?/g, function () { return siteUrl })
40
+ .replace(/\$\{?OUTPUT_DIR\}?/g, function () { return outputDir })
41
+ }
42
+
43
+ /**
44
+ * Run config.hooks.postBuild (string | string[]): each entry spawned with
45
+ * stdio inherited and a 30-second timeout (the family reference posture:
46
+ * a hung hook may cost the build its runtime, never the deploy); a hook
47
+ * that genuinely needs longer can background its own work. PRESTRUCT_SITE_URL
48
+ * and PRESTRUCT_OUTPUT_DIR are in the env, placeholders substituted via
49
+ * function replacers. Returns the list of failed entries (empty = clean).
50
+ */
51
+ export function runPostBuildHooks(config, { siteUrl, outputDir } = {}) {
52
+ const raw = config?.hooks?.postBuild
53
+ if (raw === undefined || raw === null) return []
54
+ const commands = Array.isArray(raw) ? raw : [raw]
55
+ if (commands.length === 0) return []
56
+
57
+ const url = siteUrl ?? config.siteUrl ?? ''
58
+ const out = outputDir ?? 'dist'
59
+ const failures = []
60
+ let gated = false
61
+
62
+ for (const entry of commands) {
63
+ if (typeof entry !== 'string' || entry.trim() === '') {
64
+ console.log('[prestruct] hooks.postBuild entry is not a non-empty string command - skipped')
65
+ continue
66
+ }
67
+ const command = substitutePlaceholders(entry, { siteUrl: url, outputDir: out })
68
+
69
+ if (config.runHooks !== true) {
70
+ console.log('[prestruct] PostBuild hook (not run - set runHooks: true to enable): '
71
+ + command.slice(0, 100))
72
+ gated = true
73
+ continue
74
+ }
75
+
76
+ console.log('[prestruct] PostBuild hook: running ' + command.slice(0, 76) + '...')
77
+ try {
78
+ execSync(command, { stdio: 'inherit', timeout: 30 * 1000, env: hookEnv({ siteUrl: url, outputDir: out }) })
79
+ console.log('[prestruct] PostBuild hook: done')
80
+ } catch (e) {
81
+ console.log('[prestruct] PostBuild hook: FAILED (exit ' + (e.status ?? e.message) + ')')
82
+ // Timeout kills surface as a signal, not an exit code - record it so a
83
+ // hung hook is distinguishable from a clean nonzero exit.
84
+ failures.push({ command: entry, status: e.status, signal: e.signal })
85
+ }
86
+ }
87
+
88
+ if (gated && failures.length === 0) {
89
+ console.log('[prestruct] hooks.postBuild is configured but no hook ran '
90
+ + '(opt-in gate: set runHooks: true in ssr.config.js)')
91
+ }
92
+ return failures
93
+ }