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/init/package.json
CHANGED
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prestruct",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Build-time SEO prerender layer for Vite + React + Cloudflare Pages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"build": "vite build && node scripts/inject-brand.js && node scripts/prerender.js",
|
|
7
|
+
"build": "vite build && node scripts/inject-brand.js && node scripts/prerender.js && node scripts/audit-if-configured.cjs",
|
|
8
8
|
"test": "vitest",
|
|
9
9
|
"test:run": "vitest run",
|
|
10
|
-
"test:ui": "vitest --ui",
|
|
11
10
|
"lint": "eslint . --ext js,mjs",
|
|
12
11
|
"lint:fix": "eslint . --ext js,mjs --fix"
|
|
13
12
|
},
|
|
13
|
+
"overrides": {
|
|
14
|
+
"tinypool": "^2.2.0"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
14
19
|
"peerDependencies": {
|
|
15
|
-
"react": ">=18",
|
|
16
|
-
"react-dom": ">=18",
|
|
17
|
-
"react-router-dom": ">=6",
|
|
18
|
-
"vite": ">=5"
|
|
20
|
+
"react": ">=18 <19",
|
|
21
|
+
"react-dom": ">=18 <19",
|
|
22
|
+
"react-router-dom": ">=6 <8",
|
|
23
|
+
"vite": ">=5 <8"
|
|
19
24
|
},
|
|
20
25
|
"devDependencies": {
|
|
21
26
|
"@vitejs/plugin-react": "^4.3.1",
|
|
22
27
|
"eslint": "^9.0.0",
|
|
23
|
-
"vite": "^
|
|
24
|
-
"vitest": "^
|
|
28
|
+
"vite": "^6.4.5",
|
|
29
|
+
"vitest": "^5.0.5",
|
|
25
30
|
"react": "^18.3.1",
|
|
26
31
|
"react-dom": "^18.3.1",
|
|
27
32
|
"react-router-dom": "^6.30.1"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// audit-if-configured.cjs - init-owned build wiring (NOT engine-synced).
|
|
3
|
+
//
|
|
4
|
+
// The template's build script chains the audit so a scaffolded, CONFIGURED
|
|
5
|
+
// project fails its own build on head regressions (docs/guides/cli.md).
|
|
6
|
+
// But the tracked init/ tree itself carries no ssr.config.js (it is
|
|
7
|
+
// gitignored on purpose): inject-brand skips, the shell ships placeholder
|
|
8
|
+
// meta, and the audit CORRECTLY grades that red (6 fails / 9 warns on the
|
|
9
|
+
// bare shell). CI and the template's own self-build must not ride that
|
|
10
|
+
// red. Guard: run the audit only when ssr.config.js exists; otherwise
|
|
11
|
+
// refuse silently-in-the-good-sense - named reason, exit 0.
|
|
12
|
+
//
|
|
13
|
+
// Configured hosts never see this message differ: audit exits carry
|
|
14
|
+
// straight through (stdio inherited, nonzero exit fails the build).
|
|
15
|
+
'use strict'
|
|
16
|
+
const fs = require('fs')
|
|
17
|
+
const { execFileSync } = require('child_process')
|
|
18
|
+
const path = require('path')
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
21
|
+
if (fs.existsSync(path.join(ROOT, 'ssr.config.js'))) {
|
|
22
|
+
execFileSync('node', [path.join(__dirname, 'audit.js')], { stdio: 'inherit' })
|
|
23
|
+
} else {
|
|
24
|
+
console.log('[audit] skipped: ssr.config.js absent (unconfigured scaffold) --')
|
|
25
|
+
console.log('[audit] after prestruct init / your first config, the build audit gates itself.')
|
|
26
|
+
}
|
|
@@ -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(/&/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(/&/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
|
+
}
|