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 +37 -46
- package/bin/cli.mjs +218 -10
- package/engine/scripts/audit.js +530 -0
- package/engine/scripts/hooks.js +93 -0
- package/engine/scripts/indexnow.js +139 -0
- package/engine/scripts/inject-brand.js +112 -10
- package/engine/scripts/ogimage.js +165 -0
- package/engine/scripts/prerender.js +632 -104
- package/engine/scripts/validate-config.js +103 -1
- package/engine/src/hooks/usePageMeta.js +11 -2
- package/engine/src/islands.js +23 -4
- package/engine/ssr.config.example.js +196 -0
- package/init/VERSION +1 -1
- package/init/ecosystem.cjs +1 -1
- package/init/package-lock.json +375 -407
- package/init/package.json +14 -9
- package/init/scripts/audit-if-configured.cjs +26 -0
- package/init/scripts/audit.js +530 -0
- package/init/scripts/hooks.js +93 -0
- package/init/scripts/indexnow.js +139 -0
- package/init/scripts/inject-brand.js +112 -10
- package/init/scripts/ogimage.js +165 -0
- package/init/scripts/prerender.js +632 -104
- package/init/scripts/proxy.js +100 -17
- package/init/scripts/proxy.worker.js +150 -33
- package/init/scripts/validate-config.js +103 -1
- package/init/src/App.jsx +7 -1
- package/init/src/AppLayout.jsx +8 -1
- package/init/src/hooks/usePageMeta.js +11 -2
- package/init/src/islands.js +23 -4
- package/init/ssr.config.example.js +45 -0
- package/package.json +24 -10
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Prestruct
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
migration, no runtime SSR.
|
|
3
|
+
[](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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
130
|
+
- Node 20+
|
|
137
131
|
|
|
138
132
|
---
|
|
139
133
|
|
|
140
134
|
## Publishing state
|
|
141
135
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
156
|
-
- [
|
|
157
|
-
- [
|
|
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
|
|
47
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 '
|
|
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)
|