prestruct 1.1.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,62 +89,43 @@ 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`. |
102
- | `packs/` | In-repo crashtest pack (`public/prestruct-proxy` v0.2.0) attacking the bot-proxy pair on both runtimes. |
95
+ | `packs/` | In-repo crashtest pack (`public/prestruct-proxy` v0.4.0) attacking the bot-proxy pair on both runtimes. |
103
96
  | `docs/` | Full docs site, AGENTS-built with Pagefind. |
104
97
  | `tests/` | vitest battery, including the engine sync contract test. |
105
98
 
106
- If you touch `engine/`, run `node scripts/sync-engine.mjs` or CI will fail the
107
- 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).
108
100
 
109
101
  ---
110
102
 
111
103
  ## Bot proxy (optional, additive)
112
104
 
113
- Prerendered HTML covers every route in `ssr.config.js`. Everything else serves
114
- the SPA shell. The optional render proxy intercepts bots and serves live
115
- Puppeteer HTML for the rest -- deploy it on a cheap VPS
116
- ([README-proxy.md](./README-proxy.md), Option A: Node + Puppeteer) or as a
117
- Cloudflare Worker with Browser Rendering (Option B). Humans always get static
118
- 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.
119
106
 
120
107
  ---
121
108
 
122
109
  ## Crash testing (in-repo pack)
123
110
 
124
- `packs/public/prestruct-proxy` (pack v0.2.0) is this repo's own adversarial
125
- pack for the bot-proxy pair above: four wire-level contracts (method matrix,
126
- hostile-path fuzz, bot-render 302 fallback, refresh-secret strict parity),
127
- run through the [crashtest](https://github.com/dhaupin/crashtest) harness
128
- against both runtimes. Attack route names come from the host `moduleCtx`, so
129
- the pack runs against any wiring. Latest results live in
130
- [labs/hardening.md](./labs/hardening.md), the CI-comparable ledger in
131
- [labs/crashledger.json](./labs/crashledger.json).
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).
132
112
 
133
- The pack travels with the checkout, not the npm tarball. Adopting it from
134
- another repo is one line in that repo's `crashtest.config.js`:
135
- `packs: ['/abs/path/to/prestruct/packs']` -- contract table and runbook in
136
- [packs/README.md](./packs/README.md).
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).
137
114
 
138
115
  ---
139
116
 
140
117
  ## Deploy
141
118
 
142
- - Cloudflare Pages: [docs/guides/cloudflare.md](./docs/guides/cloudflare.md)
143
- (`npm run build`, output `dist/`, Node 20+).
119
+ - Cloudflare Pages: [docs/guides/cloudflare.md](./docs/guides/cloudflare.md) (`npm run build`, output `dist/`, Node 20+).
144
120
  - Any static host works too: `dist/` is a plain static folder.
145
121
 
146
122
  ---
147
123
 
148
124
  ## Stack
149
125
 
150
- - Vite 5+ (examples and the template battery build on Vite 6)
126
+ - Vite 5+ (the example lane runs Vite 7; the template battery builds on Vite 6)
151
127
  - React 18+
152
- - React Router v6 and v7 both work: v6 via the `./server.js` subpath, v7 via
153
- the package-root fallback -- scaffold imports use the namespace+destructure
154
- form so CJS v7 passes vite SSR (docs and generated HTML render from v7 live)
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)
155
129
  - Cloudflare Pages default; any static host otherwise
156
130
  - Node 20+
157
131
 
@@ -159,29 +133,19 @@ another repo is one line in that repo's `crashtest.config.js`:
159
133
 
160
134
  ## Publishing state
161
135
 
162
- **prestruct is on npm: <https://www.npmjs.com/package/prestruct>** (1.1.0,
163
- published via `publish.yml`, GitHub Actions OIDC trusted publishing with
164
- npm provenance -- verify with `npm audit signatures`). Tags drive releases:
165
- `v*` triggers npm publish plus the GitHub release archive (template zip with
166
- a self-testing battery). `main` may be ahead of the latest tag; its version
167
- pins are the next planned release.
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.
168
137
 
169
- Local install test from the registry tarball (35 files) passed: `init` never
170
- clobbers, `build` produces full `dist/` (baked meta, sitemap, 404). The
171
- release-body template inside `release.yml` was corrected in the v1.0.0
172
- aftermath to match what the archive actually contains.
138
+ Local install test from the registry tarball passed: `init` never clobbers, `build` produces full `dist/` (baked meta, sitemap, 404).
173
139
 
174
- **Legal:** using prestruct means agreeing to [LICENSE](./LICENSE) and
175
- [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.
176
141
 
177
142
  ---
178
143
 
179
144
  ## Read next
180
145
 
181
- - [AGENTS.md](./AGENTS.md) -- engineering history, gotchas, hard rules,
182
- what's in/out of scope.
146
+ - [AGENTS.md](./AGENTS.md) -- engineering history, gotchas, hard rules, what's in/out of scope.
183
147
  - [labs/prd-prestruct.md](./labs/prd-prestruct.md) -- roadmap.
184
- - [docs/](./docs/index.md) -- full site (SEO, routing, islands, proxy,
185
- Cloudflare).
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).
186
150
 
187
151
  _No runtime deps added. Config is the API._
package/bin/cli.mjs CHANGED
@@ -9,6 +9,14 @@
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)