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.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Prestruct
2
2
 
3
- **Build-time SEO prerender for Vite + React.** Validate your `ssr.config.js`,
4
- build, inject meta, prerender every route to static HTML. No framework
5
- migration, no runtime SSR.
3
+ [![crashtest proxy hardening](https://img.shields.io/github/actions/workflow/status/dhaupin/prestruct/crashtest.yml?branch=main&label=crashtest%20hardening)](https://github.com/dhaupin/prestruct/actions/workflows/crashtest.yml)
4
+
5
+ **Build-time SEO prerender for Vite + React.** Validate your `ssr.config.js`, build, inject meta, prerender every route to static HTML. No framework migration, no runtime SSR.
6
+
7
+ Status: **1.2.0, released from the v1.2.0 tag.** npm carries the latest tagged release. `package.json` is the single version source; this line and the side package.jsons are held to it by a test, so a release cannot leave one behind.
6
8
 
7
9
  ---
8
10
 
@@ -16,16 +18,11 @@ cp ssr.config.example.js ssr.config.js # then edit siteUrl + routes
16
18
  npx prestruct build
17
19
  ```
18
20
 
19
- Build runs the whole pipeline: validate -> `vite build` -> inject meta ->
20
- prerender routes. Output lands in `dist/` with one `index.html` per route,
21
- `sitemap.xml`, and `404.html`.
21
+ Build runs the whole pipeline: validate -> `vite build` -> inject meta -> prerender routes. Output lands in `dist/` with one `index.html` per route, `sitemap.xml`, and `404.html`.
22
22
 
23
- **Live demo:** https://prestruct.creadev.org (built with prestruct; source in
24
- `example/`).
23
+ **Live demo:** https://prestruct.creadev.org (built with prestruct; source in `example/`).
25
24
 
26
- Prefer raw npm scripts over the CLI? The `init/` template scaffolds
27
- `scripts/prerender.js` + `scripts/inject-brand.js` you can wire into
28
- `package.json` directly -- see [docs/guides/cli.md](./docs/guides/cli.md).
25
+ Prefer raw npm scripts over the CLI? The `init/` template scaffolds `scripts/prerender.js` + `scripts/inject-brand.js` you can wire into `package.json` directly -- see [docs/guides/cli.md](./docs/guides/cli.md).
29
26
 
30
27
  ---
31
28
 
@@ -50,8 +47,7 @@ export default {
50
47
  }
51
48
  ```
52
49
 
53
- Full reference (platform, islands, JSON-LD, `fetchRoutes()`, deployId footer,
54
- `PRESTRUCT_STRICT`): [docs/guides/config.md](./docs/guides/config.md).
50
+ Full reference (platform, islands, JSON-LD, `fetchRoutes()`, deployId footer, `PRESTRUCT_STRICT`): [docs/guides/config.md](./docs/guides/config.md).
55
51
 
56
52
  ---
57
53
 
@@ -61,13 +57,12 @@ Full reference (platform, islands, JSON-LD, `fetchRoutes()`, deployId footer,
61
57
  npx prestruct init # scaffold template files, never clobber
62
58
  npx prestruct build # validate -> vite build -> inject -> prerender
63
59
  npx prestruct validate # check ssr.config.js; exit 1 on bad config
60
+ npx prestruct audit # grade the baked dist/; exit 1 on FAIL rows
61
+ npx prestruct indexnow # submit the baked sitemap URLs (--dry-run is CI-safe)
64
62
  npx prestruct clean # clear the prerender route cache
65
63
  ```
66
64
 
67
- Incremental builds: `.prestruct/cache/` stores rendered HTML; unchanged routes
68
- are skipped on rebuild unless the shell changes. Fix a config mistake silently?
69
- Rebuild with a validation pass first (`npx prestruct validate` prints errors
70
- line-by-line).
65
+ Incremental builds: `.prestruct/cache/` stores rendered HTML; unchanged routes are skipped on rebuild unless the shell changes. Fix a config mistake silently? Rebuild with a validation pass first (`npx prestruct validate` prints errors line-by-line).
71
66
 
72
67
  ---
73
68
 
@@ -85,9 +80,7 @@ prestruct build (or npm run build in the template)
85
80
  `- prerender vite.ssrLoadModule per route -> static HTML
86
81
  ```
87
82
 
88
- Static HTML is served directly at deploy time (Cloudflare Pages default). No
89
- runtime SSR. Client-only dynamic content mounts into `<pre-island>` elements
90
- after hydration.
83
+ Static HTML is served directly at deploy time (Cloudflare Pages default). No runtime SSR. Client-only dynamic content mounts into `<pre-island>` elements after hydration.
91
84
 
92
85
  ---
93
86
 
@@ -96,65 +89,63 @@ after hydration.
96
89
  | Path | Purpose |
97
90
  |------|---------|
98
91
  | `engine/` | Single source of truth for `prerender.js`, `inject-brand.js`, `validate-config.js`, islands + usePageMeta. |
99
- | `bin/cli.mjs` | The `prestruct` executable (init/build/validate/clean). |
92
+ | `bin/cli.mjs` | The `prestruct` executable (init/build/validate/audit/indexnow/clean). |
100
93
  | `init/` | Project template `prestruct init` scaffolds, including `scripts/proxy.js` (VPS) and `proxy.worker.js` (Cloudflare Worker). |
101
94
  | `example/` | Working demo site (8 routes), kept byte-identical to engine via `scripts/sync-engine.mjs`. |
95
+ | `packs/` | In-repo crashtest pack (`public/prestruct-proxy` v0.4.0) attacking the bot-proxy pair on both runtimes. |
102
96
  | `docs/` | Full docs site, AGENTS-built with Pagefind. |
103
97
  | `tests/` | vitest battery, including the engine sync contract test. |
104
98
 
105
- If you touch `engine/`, run `node scripts/sync-engine.mjs` or CI will fail the
106
- byte-identical check. Details in [AGENTS.md](./AGENTS.md).
99
+ If you touch `engine/`, run `node scripts/sync-engine.mjs` or CI will fail the byte-identical check. Details in [AGENTS.md](./AGENTS.md).
107
100
 
108
101
  ---
109
102
 
110
103
  ## Bot proxy (optional, additive)
111
104
 
112
- Prerendered HTML covers every route in `ssr.config.js`. Everything else serves
113
- the SPA shell. The optional render proxy intercepts bots and serves live
114
- Puppeteer HTML for the rest -- deploy it on a cheap VPS
115
- ([README-proxy.md](./README-proxy.md), Option A: Node + Puppeteer) or as a
116
- Cloudflare Worker with Browser Rendering (Option B). Humans always get static
117
- CF Pages output; the two layers stack, they don't replace each other.
105
+ Prerendered HTML covers every route in `ssr.config.js`. Everything else serves the SPA shell. The optional render proxy intercepts bots and serves live Puppeteer HTML for the rest -- deploy it on a cheap VPS ([README-proxy.md](./README-proxy.md), Option A: Node + Puppeteer) or as a Cloudflare Worker with Browser Rendering (Option B). Humans always get static CF Pages output; the two layers stack, they don't replace each other.
106
+
107
+ ---
108
+
109
+ ## Crash testing (in-repo pack)
110
+
111
+ `packs/public/prestruct-proxy` (pack v0.4.0) is this repo's own adversarial pack for the bot-proxy pair above: five wire-level contracts (method matrix, hostile-path fuzz, bot-render 302 fallback, refresh-secret strict parity, cache-key variant parity), run through the [crashtest](https://github.com/dhaupin/crashtest) harness against both runtimes. Attack route names come from the host `moduleCtx`, so the pack runs against any wiring. Latest results live in [labs/hardening.md](./labs/hardening.md), the CI-comparable ledger in [labs/crashledger.json](./labs/crashledger.json).
112
+
113
+ The pack travels with the checkout, not the npm tarball. Adopting it from another repo is one line in that repo's `crashtest.config.js`: `packs: ['/abs/path/to/prestruct/packs']` -- contract table and runbook in [packs/README.md](./packs/README.md).
118
114
 
119
115
  ---
120
116
 
121
117
  ## Deploy
122
118
 
123
- - Cloudflare Pages: [docs/guides/cloudflare.md](./docs/guides/cloudflare.md)
124
- (`npm run build`, output `dist/`, Node 18+).
119
+ - Cloudflare Pages: [docs/guides/cloudflare.md](./docs/guides/cloudflare.md) (`npm run build`, output `dist/`, Node 20+).
125
120
  - Any static host works too: `dist/` is a plain static folder.
126
121
 
127
122
  ---
128
123
 
129
124
  ## Stack
130
125
 
131
- - Vite 5+
126
+ - Vite 5+ (the example lane runs Vite 7; the template battery builds on Vite 6)
132
127
  - React 18+
133
- - React Router v6 (v7 works -- engine falls back to the package root when the
134
- `./server.js` subpath is absent)
128
+ - React Router v6 and v7 both work: v6 via the `./server.js` subpath, v7 via the package-root fallback -- scaffold imports use the namespace+destructure form so CJS v7 passes vite SSR (docs and generated HTML render from v7 live)
135
129
  - Cloudflare Pages default; any static host otherwise
136
- - Node 18+
130
+ - Node 20+
137
131
 
138
132
  ---
139
133
 
140
134
  ## Publishing state
141
135
 
142
- `package.json` is at 1.0.0 but **not tagged and not published**. `publish.yml`
143
- (OIDC trusted publishing on `v*` tags) is wired and dormant -- it
144
- fires nothing until the first tag exists, by owner decision. When it fires,
145
- provenance is automatic and no stored npm token is needed.
136
+ **prestruct is on npm: <https://www.npmjs.com/package/prestruct>** (1.2.0 is the latest tagged release, published from the v1.2.0 tag). Releases go through `publish.yml`, GitHub Actions OIDC trusted publishing with npm provenance -- verify with `npm audit signatures`. The tag drives the whole lane: npm publish, then a GitHub release cut from the same tag with its notes extracted from `CHANGELOG.md`. `main` may be ahead of the latest tag; its version pins are the next planned release.
137
+
138
+ Local install test from the registry tarball passed: `init` never clobbers, `build` produces full `dist/` (baked meta, sitemap, 404).
146
139
 
147
- **Legal:** using prestruct means agreeing to [LICENSE](./LICENSE) and
148
- [LEGAL.md](./LEGAL.md). Fair-Code: commercial SaaS use is restricted.
140
+ **Legal:** using prestruct means agreeing to [LICENSE](./LICENSE) and [LEGAL.md](./LEGAL.md). Fair-Code: commercial SaaS use is restricted.
149
141
 
150
142
  ---
151
143
 
152
144
  ## Read next
153
145
 
154
- - [AGENTS.md](./AGENTS.md) -- engineering history, gotchas, hard rules,
155
- what's in/out of scope.
156
- - [labs/prd.md](./labs/prd.md) -- roadmap.
157
- - [docs/](./docs/index.md) -- full site (SEO, routing, islands, proxy,
158
- Cloudflare).
146
+ - [AGENTS.md](./AGENTS.md) -- engineering history, gotchas, hard rules, what's in/out of scope.
147
+ - [labs/prd-prestruct.md](./labs/prd-prestruct.md) -- roadmap.
148
+ - [docs/](./docs/index.md) -- full site (SEO, routing, islands, proxy, Cloudflare).
149
+ - **The workshop** -> [Creadev.org](https://creadev.org) (the same home as the docs, the demo, and the npm package).
159
150
 
160
151
  _No runtime deps added. Config is the API._
package/bin/cli.mjs CHANGED
@@ -2,13 +2,21 @@
2
2
  /**
3
3
  * bin/cli.mjs - prestruct CLI
4
4
  * ============================
5
- * Commands (spec'd in labs/prd.md Phase 2):
5
+ * Commands (spec'd in labs/prd-prestruct.md Phase 2):
6
6
  *
7
7
  * prestruct init [dir] Scaffold the template into dir (default: cwd).
8
8
  * Never clobbers existing files.
9
9
  * prestruct build Validate config, run vite build, inject meta,
10
10
  * prerender routes -- full pipeline from ssr.config.js.
11
11
  * prestruct validate Load + validate ssr.config.js; exit 1 on bad config.
12
+ * prestruct audit Verify the BAKED dist/ (head hygiene, sitemap,
13
+ * robots, 404 noindex) without touching it; exit 1
14
+ * on FAIL-class findings, warnings never fail.
15
+ * prestruct indexnow Key file into dist/ + submit the baked sitemap
16
+ * URLs to api.indexnow.org (--dry-run prints the
17
+ * request body without touching the network); the
18
+ * submission only counts once the output is DEPLOYED
19
+ * (keyLocation must resolve).
12
20
  * prestruct help This text.
13
21
  *
14
22
  * The CLI runs inside the USER PROJECT: config comes from <workdir>/ssr.config.js,
@@ -26,15 +34,24 @@ process.on('unhandledRejection', (e) => console.error('[prestruct] unhandled rej
26
34
  const cliDir = path.dirname(fileURLToPath(import.meta.url))
27
35
  const packageRoot = path.resolve(cliDir, '..')
28
36
 
29
- const { validateConfig, formatDiagnostics } = await import(
37
+ const { validateConfig, formatDiagnostics, configKeyWarnings, formatConfigWarnings } = await import(
30
38
  pathToFileURL(path.join(packageRoot, 'engine/scripts/validate-config.js'))
31
39
  )
32
40
  const { injectBrand } = await import(
33
41
  pathToFileURL(path.join(packageRoot, 'engine/scripts/inject-brand.js'))
34
42
  )
35
- const { prerender, cleanCache, enginePaths } = await import(
43
+ const { prerender, cleanCache, enginePaths, generateSitemap, assertXmlSafe } = await import(
36
44
  pathToFileURL(path.join(packageRoot, 'engine/scripts/prerender.js'))
37
45
  )
46
+ const { auditOutput, auditExternalLinks, auditInternalLinks } = await import(
47
+ pathToFileURL(path.join(packageRoot, 'engine/scripts/audit.js'))
48
+ )
49
+ const { runPostBuildHooks } = await import(
50
+ pathToFileURL(path.join(packageRoot, 'engine/scripts/hooks.js'))
51
+ )
52
+ const { submitIndexNow, emitIndexNowKeyFile, resolveIndexNowKey, readSitemapUrls } = await import(
53
+ pathToFileURL(path.join(packageRoot, 'engine/scripts/indexnow.js'))
54
+ )
38
55
 
39
56
  const HELP = `
40
57
  prestruct -- build-time SEO prerender for Vite + React
@@ -43,20 +60,43 @@ prestruct -- build-time SEO prerender for Vite + React
43
60
  Existing files are never clobbered.
44
61
  prestruct build [--force] [--clean]
45
62
  Full pipeline: validate -> vite build -> inject
46
- meta -> prerender routes. --force rerenders all
47
- routes; --clean clears the incremental cache first.
63
+ meta -> prerender routes -> hooks.postBuild ->
64
+ audit (FAIL-class findings fail the build).
65
+ --force rerenders all routes; --clean clears the
66
+ incremental cache first.
48
67
  prestruct validate Validate ssr.config.js (exit 1 on bad config).
68
+ prestruct audit [--json] [--external] [--internal] [dist]
69
+ Verify the BAKED output (per-page head hygiene,
70
+ duplicate-tag probes, og:image raster check,
71
+ JSON-LD parse, sitemap/robots/404-noindex) without
72
+ touching it. Exit 0 clean / 1 FAIL-class findings
73
+ (length bands and absent companions are report-
74
+ grade warnings and never fail). --external also
75
+ HEAD-checks up to 50 unique external hrefs
76
+ (warn-grade, needs live network); --internal lists
77
+ unresolved internal hrefs (warn-grade). A target
78
+ dir defaults to the config-owning project's dist/.
79
+ prestruct indexnow [--dry-run] [dist]
80
+ Write the IndexNow key file into dist/ and POST
81
+ the baked sitemap URLs to api.indexnow.org.
82
+ --dry-run prints the request body untouched
83
+ (CI-safe). Runs against a DEPLOYED output only:
84
+ keyLocation must resolve for engines to accept.
49
85
  prestruct clean Clear the prerender route cache.
86
+ prestruct --version Print the engine version (also -v).
50
87
  prestruct help This text.
51
88
 
52
89
  Config lives in ssr.config.js -- copy ssr.config.example.js to start.
53
90
  `
54
91
 
55
92
  function findWorkdir(args) {
56
- // Explicit workdir wins; otherwise walk up from cwd until ssr.config.js is found
57
- const [first] = args
58
- if (first && !first.startsWith('-')) {
59
- return path.resolve(first)
93
+ // Explicit workdir wins; otherwise walk up from cwd until ssr.config.js is
94
+ // found. Positional is position-independent (first non-flag), same contract
95
+ // as cmdInit/cmdAudit -- `prestruct build --force dir` used to read only
96
+ // args[0], see the flag, and silently build from cwd instead of dir.
97
+ const positional = args.find(a => !a.startsWith('-'))
98
+ if (positional) {
99
+ return path.resolve(positional)
60
100
  }
61
101
  let dir = process.cwd()
62
102
  while (dir !== path.dirname(dir)) {
@@ -85,14 +125,45 @@ async function loadConfig(workdir, { required = true } = {}) {
85
125
 
86
126
  function report(config) {
87
127
  // Returns the ok BOOLEAN (not an object) -- callers must not destructure it.
128
+ // Key warnings (#52) print alongside shape diagnostics but never fail: a
129
+ // typo'd key that does nothing says so loudly, diagnostics stay gates-free.
88
130
  const { ok, errors } = validateConfig(config)
89
131
  if (!ok) formatDiagnostics(errors).forEach(line => console.error(line))
132
+ formatConfigWarnings(configKeyWarnings(config)).forEach(line => console.warn(line))
90
133
  return ok
91
134
  }
92
135
 
136
+ // -- quiet-mistake refusal (#54) --------------------------------------------
137
+ //
138
+ // A dash-shaped arg that is not a known flag was previously SILENTLY IGNORED
139
+ // (build/validate/clean walked past it; init skipped it). The user meant a
140
+ // flag and got, or lost, behavior they asked for with no signal -- the same
141
+ // class freestruct hardened (26.16). Refuse loudly, exit 1, name the flags.
142
+ // --help is a first-class flag on every command; print help and exit 0.
143
+
144
+ function checkFlags(command, args, knownFlags) {
145
+ if (args.includes('--help')) {
146
+ console.log(HELP)
147
+ process.exit(0)
148
+ }
149
+ const unknown = args.filter(a => a.startsWith('-') && !knownFlags.includes(a))
150
+ if (unknown.length) {
151
+ console.error(`[prestruct] unknown flag(s): ${unknown.join(' ')} - the command takes no flags beyond: ${knownFlags.join(' ') || '(none)'}`)
152
+ console.error('[prestruct] run prestruct help for the command list')
153
+ process.exit(1)
154
+ }
155
+ }
156
+
157
+ const INIT_FLAGS = []
158
+ const BUILD_FLAGS = ['--force', '-f', '--clean']
159
+ const AUDIT_FLAGS = ['--json', '--external', '--internal']
160
+ const INDEXNOW_FLAGS = ['--dry-run']
161
+ const NO_FLAGS = []
162
+
93
163
  // -- init ----------------------------------------------------------------------
94
164
 
95
165
  function cmdInit(args) {
166
+ checkFlags('init', args, INIT_FLAGS)
96
167
  const target = path.resolve(args.find(a => !a.startsWith('-')) || '.')
97
168
  const templateRoot = path.join(packageRoot, 'init')
98
169
  if (!fs.existsSync(templateRoot)) {
@@ -135,6 +206,7 @@ function cmdInit(args) {
135
206
  // -- build ---------------------------------------------------------------------
136
207
 
137
208
  async function cmdBuild(args) {
209
+ checkFlags('build', args, BUILD_FLAGS)
138
210
  const workdir = findWorkdir(args)
139
211
  const config = await loadConfig(workdir)
140
212
  if (!config) process.exit(1)
@@ -171,11 +243,135 @@ async function cmdBuild(args) {
171
243
  console.error(`[prestruct] prerender failed for ${result.failed}/${result.total} route(s) -- failing build`)
172
244
  process.exit(1)
173
245
  }
246
+
247
+ // hooks.postBuild (#41): the pipeline owns these, so direct engine/CI
248
+ // invocations can no longer silently skip them the way the old npm-script
249
+ // chain could. Nothing executes without runHooks: true (surfaced loudly);
250
+ // per-hook failures collect here and turn the build itself red.
251
+ const hookFailures = runPostBuildHooks(config, {
252
+ siteUrl: config.siteUrl || '',
253
+ outputDir: path.join(workdir, 'dist'),
254
+ })
255
+ if (hookFailures.length > 0) {
256
+ console.error(`[prestruct] ${hookFailures.length} postBuild hook(s) failed -- failing build`)
257
+ process.exit(1)
258
+ }
259
+
260
+ // Audit gate: the docs promise `prestruct build` ends in the audit, so a
261
+ // head regression fails the build (docs/guides/cli.md). The npm-script
262
+ // chain in init/ ends with audit-if-configured.cjs; the CLI path must too,
263
+ // or CLI users get no gate. Warnings never fail, FAIL-class findings do.
264
+ const audit = auditOutput({ outputDir: path.join(workdir, 'dist'), config })
265
+ if (audit.fails.length > 0) {
266
+ console.error(`[prestruct] audit: ${audit.fails.length} FAIL-class finding(s) -- failing build`)
267
+ for (const f of audit.fails) console.error(` FAIL ${f.page}: ${f.msg}`)
268
+ process.exit(1)
269
+ }
270
+ console.log(`[prestruct] audit: 0 failures, ${audit.warns.length} warning(s) - clean`)
271
+ }
272
+
273
+ // -- indexnow ------------------------------------------------------------------
274
+ // Configless by design (mirrors audit): when a config is discoverable it
275
+ // supplies the key; without one, only --dry-run against a baked tree makes
276
+ // sense (the key comes from config; no config = nothing to submit).
277
+ async function cmdIndexNow(args) {
278
+ checkFlags('indexnow', args, INDEXNOW_FLAGS)
279
+ const dryRun = args.includes('--dry-run')
280
+ const explicitDir = args.find(a => !a.startsWith('-'))
281
+
282
+ let config = null
283
+ let workdir = process.cwd()
284
+ let dir = process.cwd()
285
+ while (dir !== path.dirname(dir)) {
286
+ const candidate = path.join(dir, 'ssr.config.js')
287
+ if (fs.existsSync(candidate)) {
288
+ workdir = dir
289
+ config = await loadConfig(dir, { required: false })
290
+ break
291
+ }
292
+ dir = path.dirname(dir)
293
+ }
294
+ const outputDir = explicitDir
295
+ ? path.resolve(process.cwd(), explicitDir)
296
+ : path.join(workdir, 'dist')
297
+
298
+ // Key-file emission runs on the verb too (not only build): a host that
299
+ // hand-curated dist/ still needs the keyLocation file to ship.
300
+ const resolved = config ? resolveIndexNowKey(config, enginePaths(path.resolve(workdir)).cacheDir) : null
301
+ if (resolved) emitIndexNowKeyFile(resolved.key, outputDir)
302
+
303
+ const result = await submitIndexNow({ config: config || {}, configDir: enginePaths(path.resolve(workdir)).cacheDir, outputDir, dryRun })
304
+ if (!result.ok) process.exit(1)
305
+ }
306
+
307
+ // -- audit ---------------------------------------------------------------------
308
+
309
+ // Configless by design: the audit verifies any baked tree. When ssr.config.js
310
+ // IS discoverable it feeds the site-local og:image/JSON-LD disk mapping and
311
+ // switches robots.txt grading on (the engine owns that file only under
312
+ // config.robots; the author's public/robots.txt is never engine-touched).
313
+ async function cmdAudit(args) {
314
+ checkFlags('audit', args, AUDIT_FLAGS)
315
+ const useJson = args.includes('--json')
316
+ const explicitDir = args.find(a => !a.startsWith('-'))
317
+
318
+ let config = null
319
+ let workdir = process.cwd()
320
+ let dir = process.cwd()
321
+ while (dir !== path.dirname(dir)) {
322
+ const candidate = path.join(dir, 'ssr.config.js')
323
+ if (fs.existsSync(candidate)) {
324
+ workdir = dir
325
+ config = await loadConfig(dir, { required: false })
326
+ break
327
+ }
328
+ dir = path.dirname(dir)
329
+ }
330
+
331
+ const outputDir = explicitDir
332
+ ? path.resolve(process.cwd(), explicitDir)
333
+ : path.join(workdir, 'dist')
334
+
335
+ // capture (never let auditOutput print its own digest) whenever the caller
336
+ // owns the JSON line below: the old `json: useJson && !--external` printed
337
+ // auditOutput's digest AND then the combined digest, so a plain `audit
338
+ // --json` emitted two concatenated JSON objects -- unparseable for jq/CI.
339
+ const result = auditOutput({ outputDir, config, capture: useJson })
340
+
341
+ const internal = args.includes('--internal') ? auditInternalLinks({ outputDir }) : []
342
+ if (internal.length && !useJson) {
343
+ console.log('[prestruct] internal link check: ' + internal.length + ' broken link(s)')
344
+ for (const b of internal) console.log(' WARN ' + b.file + ' -> ' + b.link)
345
+ }
346
+
347
+ const external = args.includes('--external') ? await auditExternalLinks({ outputDir, json: useJson }) : { warns: [] }
348
+
349
+ if (useJson) {
350
+ console.log(JSON.stringify({
351
+ ok: result.fails.length === 0,
352
+ pages: result.pages,
353
+ fails: result.fails,
354
+ warns: [
355
+ ...result.warns,
356
+ ...internal.map(b => ({ page: b.file, msg: 'broken internal link: ' + b.link })),
357
+ ...external.warns,
358
+ ],
359
+ }))
360
+ } else if (internal.length || external.warns.length) {
361
+ // Aux probes appended rows the base report never saw; pure-local audits
362
+ // keep auditOutput's single verdict (one verdict, not two).
363
+ const totalWarns = result.warns.length + internal.length + external.warns.length
364
+ console.log(result.fails.length === 0
365
+ ? '[prestruct] audit: 0 failures, ' + totalWarns + ' warning(s) - clean'
366
+ : '[prestruct] audit: ' + result.fails.length + ' failure(s), ' + totalWarns + ' warning(s)')
367
+ }
368
+ process.exit(result.fails.length > 0 ? 1 : 0)
174
369
  }
175
370
 
176
371
  // -- validate / clean ----------------------------------------------------------
177
372
 
178
373
  async function cmdValidate(args) {
374
+ checkFlags('validate', args, NO_FLAGS)
179
375
  const workdir = findWorkdir(args)
180
376
  const config = await loadConfig(workdir)
181
377
  if (!config) process.exit(1)
@@ -185,6 +381,7 @@ async function cmdValidate(args) {
185
381
  }
186
382
 
187
383
  function cmdClean(args) {
384
+ checkFlags('clean', args, NO_FLAGS)
188
385
  const workdir = findWorkdir(args)
189
386
  const p = enginePaths(path.resolve(workdir))
190
387
  cleanCache({ cacheFile: p.cacheFile })
@@ -199,8 +396,19 @@ try {
199
396
  case 'init': cmdInit(args); break
200
397
  case 'build': await cmdBuild(args); break
201
398
  case 'validate': await cmdValidate(args); break
399
+ case 'audit': await cmdAudit(args); break
400
+ case 'indexnow': await cmdIndexNow(args); break
202
401
  case 'clean': cmdClean(args); break
203
- case 'help': console.log(HELP); break
402
+ case 'version':
403
+ case '--version':
404
+ case '-v': {
405
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'))
406
+ console.log(pkg.version)
407
+ break
408
+ }
409
+ case 'help':
410
+ case '--help':
411
+ case '-h': console.log(HELP); break
204
412
  default:
205
413
  console.error(`[prestruct] unknown command: ${command}`)
206
414
  console.log(HELP)