prestruct 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,430 @@
1
+ /**
2
+ * scripts/inject-brand.js
3
+ * =======================
4
+ * Canonical engine source (engine/scripts/inject-brand.js). Deployed copies at
5
+ * scripts/inject-brand.js in init/ and example/ are generated byte-identically
6
+ * by scripts/sync-engine.mjs -- edit the engine, never the copies.
7
+ *
8
+ * Post-build script. Runs after `vite build` via `npm run build`.
9
+ * Reads ssr.config.js and injects all head meta into dist/index.html:
10
+ * - <title>
11
+ * - <meta name="description">
12
+ * - <meta name="author">
13
+ * - <meta name="keywords">
14
+ * - <link rel="canonical">
15
+ * - Open Graph tags (og:type, og:url, og:title, og:description, og:image, og:site_name)
16
+ * - Twitter Card tags
17
+ * - JSON-LD structured data (from config.buildJsonLd())
18
+ * - <meta name="deploy-id"> with build identifier
19
+ *
20
+ * Deploy ID behavior (see init/ssr.config.example.js):
21
+ * - deployId: true -- inject <meta name="deploy-id"> (default: true)
22
+ * - deployIdFooter: false -- show deploy ID in the app footer via
23
+ * data-deploy-id-footer on the root div.
24
+ * Opt-in (default: false). The attribute travels
25
+ * on the root div and survives prerendering
26
+ * (prerender.js carries root-div attrs -- W8).
27
+ *
28
+ * If config.proxy.url is set, also updates dist/_headers to add the proxy
29
+ * origin to connect-src. This is done precisely: it finds the existing
30
+ * connect-src directive and appends only if the origin isn't already there.
31
+ * Running the build twice is safe -- no duplicate entries.
32
+ *
33
+ * Module surface (importable for tests; CLI logic only runs when executed):
34
+ * toReplaceSafe(value)
35
+ * generateTitle(siteName, tagline)
36
+ * injectBrand(config, distDir, { getDeployId })
37
+ *
38
+ * Fails gracefully -- exits 0 on any error so a bad config never blocks deployment.
39
+ * The site will still deploy as a working SPA without prerendered meta.
40
+ * Set PRESTRUCT_STRICT=1 to fail on config diagnostics instead.
41
+ */
42
+
43
+ import { readFileSync, writeFileSync, existsSync } from 'fs'
44
+ import { join, resolve, dirname } from 'path'
45
+ import { execSync } from 'child_process'
46
+ import { fileURLToPath, pathToFileURL } from 'url'
47
+
48
+ import { validateConfig, formatDiagnostics } from './validate-config.js'
49
+
50
+ // -- Replacement safety --------------------------------------------------------
51
+
52
+ /**
53
+ * Neutralize replacement-pattern characters ($&, $1, ...) so user copy can
54
+ * safely be used as a String.replace() replacement string.
55
+ *
56
+ * @param {string} value - raw brand/config value
57
+ * @returns {string} replacement-safe text
58
+ */
59
+ export function toReplaceSafe(value) {
60
+ return (value == null ? '' : String(value).replace(/\$/g, '$$$$'))
61
+ }
62
+
63
+ // HTML attribute/text escaping for values landed in the shell's head tags.
64
+ // Meta values go into content="..." attributes (and <title> text): a `"` in
65
+ // a description silently breaks the attribute, and `</title>` in a title
66
+ // injects markup onto every page. applied BEFORE toReplaceSafe, which only
67
+ // handles replace() $-semantics.
68
+ /**
69
+ * Escape HTML-significant characters for attribute contexts (quotes, angle
70
+ * brackets, ampersands).
71
+ *
72
+ * @param {string} value - raw value
73
+ * @returns {string} attribute-safe value
74
+ */
75
+ export function escapeHtmlAttr(value) {
76
+ return String(value == null ? '' : value)
77
+ .replace(/&/g, '&amp;')
78
+ .replace(/</g, '&lt;')
79
+ .replace(/>/g, '&gt;')
80
+ .replace(/"/g, '&quot;')
81
+ .replace(/'/g, '&#39;')
82
+ }
83
+
84
+ // -- Title generation ---------------------------------------------------------
85
+
86
+ /**
87
+ * Compose the SEO title as "siteName | tagline" with the tagline trimmed
88
+ * when absent.
89
+ *
90
+ * @param {string} siteName - configured site name
91
+ * @param {string} tagline - configured tagline
92
+ * @returns {string} composed title
93
+ */
94
+ export function generateTitle(siteName, tagline) {
95
+ return siteName ? (tagline ? `${siteName} | ${tagline}` : siteName) : 'Site'
96
+ }
97
+
98
+ // -- Deploy ID ----------------------------------------------------------------
99
+
100
+ // Generate deploy ID from git commit hash + short timestamp
101
+ function defaultGetDeployId() {
102
+ // Stable per commit. The OLD shape appended a random base36 timestamp,
103
+ // which changed the shell on EVERY build -- and because prerender's cache
104
+ // signature covers the whole shell, that defeated the incremental cache
105
+ // on every deployId:true project (SEO/perf audit 2026-10-20: warm
106
+ // rebuilds re-rendered 8/8 pages). Per-deploy identity belongs to the
107
+ // hosting platform (CF Pages deployment id), not the HTML; the shell
108
+ // still invalidates the cache honestly when content actually changes
109
+ // (vite asset hashes + meta live in the same signature).
110
+ try {
111
+ return execSync('git rev-parse --short HEAD 2>/dev/null', { encoding: 'utf8' }).trim()
112
+ } catch {
113
+ return 'dev-local'
114
+ }
115
+ }
116
+
117
+ // -- Main injection flow ------------------------------------------------------
118
+ // export function injectBrand(config, distDir, { getDeployId } = {})
119
+ // config : loaded ssr.config.js (validated first)
120
+ // distDir : absolute path to the build output (defaults ./dist)
121
+ // getDeployId : injectable for tests; defaults to git-hash generator
122
+
123
+ /**
124
+ * Stamp the global brand meta into dist/index.html: title, description, OG,
125
+ * Twitter card, JSON-LD, deploy-id, canonical, keywords. Idempotent on
126
+ * rebuild (previously injected blocks are replaced). Library-safe: returns,
127
+ * never exits.
128
+ *
129
+ * @param {object} config - full ssr.config.js export
130
+ * @param {string} [distDir=resolve('dist')] - build output directory
131
+ * @param {{getDeployId?: Function, projectRoot?: string}} [opts] - injection hooks
132
+ * @returns {void}
133
+ */
134
+ export function injectBrand(config, distDir = resolve('dist'), { getDeployId = defaultGetDeployId, projectRoot } = {}) {
135
+ const {
136
+ platform = 'cloudflare',
137
+ siteUrl, siteName, author, tagline, ogImage, keywords,
138
+ routes = [], buildJsonLd, proxy = {},
139
+ } = config
140
+
141
+ // Only generate Cloudflare-specific files (_headers, _redirects)
142
+ // Future: vercel gets vercel.json, netlify gets netlify.toml
143
+ if (platform === 'cloudflare') {
144
+ // _headers and _redirects are copied from init/public/ at build time
145
+ // This section reserved for any CF-specific injection logic
146
+ }
147
+
148
+ const htmlPath = join(distDir, 'index.html')
149
+
150
+ if (!existsSync(htmlPath)) {
151
+ console.warn('[inject-brand] dist/index.html not found -- skipping')
152
+ process.exit(0)
153
+ }
154
+
155
+ // Homepage meta as the global description fallback
156
+ const homeRoute = routes.find(r => r.path === '/') || {}
157
+ const title = generateTitle(siteName, tagline)
158
+ const description = homeRoute.meta?.description || null
159
+ const canonical = `${siteUrl}/`
160
+
161
+ const titleSafe = toReplaceSafe(escapeHtmlAttr(title))
162
+ const descriptionSafe = toReplaceSafe(escapeHtmlAttr(description))
163
+ const authorSafe = toReplaceSafe(escapeHtmlAttr(author))
164
+ const keywordsSafe = toReplaceSafe(escapeHtmlAttr(keywords))
165
+ const canonicalSafe = toReplaceSafe(escapeHtmlAttr(canonical))
166
+ const ogImageSafe = toReplaceSafe(escapeHtmlAttr(ogImage))
167
+ const siteNameSafe = toReplaceSafe(escapeHtmlAttr(siteName))
168
+
169
+ let html = readFileSync(htmlPath, 'utf8')
170
+
171
+ // -- Deploy ID --------------------------------------------------------------
172
+
173
+ // Deploy ID meta tag is on by default. Footer display is opt-in (default false).
174
+ const deployIdEnabled = config.deployId !== false
175
+ const showFooter = config.deployIdFooter === true
176
+
177
+ if (deployIdEnabled) {
178
+ const deployId = getDeployId()
179
+ console.log(` Deploy ID: ${deployId}`)
180
+
181
+ // Remove previously injected deploy-id (safe to run on rebuild)
182
+ html = html.replace(/<meta name="deploy-id"[^>]*>/g, '')
183
+
184
+ // Inject deploy-id meta tag
185
+ html = html.replace('</head>', ` <meta name="deploy-id" content="${deployId}" />\n</head>`)
186
+ } else {
187
+ // Remove any existing deploy-id meta tag if disabled
188
+ html = html.replace(/<meta name="deploy-id"[^>]*>/g, '')
189
+ console.log(' Deploy ID: disabled')
190
+ }
191
+
192
+ // Footer attribute is separate from the meta tag: applied when opted in,
193
+ // stripped when not (safe to run on rebuild either way). It travels on the
194
+ // root div and is carried through prerendering by replaceRootDiv.
195
+ if (showFooter) {
196
+ html = html.replace(/<div id="root"/, '<div id="root" data-deploy-id-footer')
197
+ } else {
198
+ html = html.replace(/ data-deploy-id-footer/g, '')
199
+ }
200
+
201
+ // -- Primary meta -----------------------------------------------------------
202
+
203
+ // Title regex deliberately has no dotall 's' flag: shells can carry the
204
+ // string '<title>' inside HTML comments (build instructions), and a dotall
205
+ // match would pair that comment fragment with the REAL closing </title> far
206
+ // below -- corrupting the comment tree and landing the replacement in the
207
+ // wrong place. Single-line matching means fake occurrences without a
208
+ // matching closer are skipped automatically. Caught by the tarball e2e pass.
209
+ html = html.replace(/<title>[^<]*<\/title>/,
210
+ `<title>${titleSafe}</title>`)
211
+
212
+ if (description) {
213
+ html = html.replace(/<meta name="description"[^>]*>/,
214
+ `<meta name="description" content="${descriptionSafe}" />`)
215
+ }
216
+
217
+ html = html.replace(/<meta name="author"[^>]*>/,
218
+ `<meta name="author" content="${authorSafe}" />`)
219
+
220
+ if (keywords) {
221
+ html = html.replace(/<meta name="keywords"[^>]*>/,
222
+ `<meta name="keywords" content="${keywordsSafe}" />`)
223
+ }
224
+
225
+ html = html.replace(/<link rel="canonical"[^>]*>/,
226
+ `<link rel="canonical" href="${canonicalSafe}" />`)
227
+
228
+ // -- Remove previously injected OG / JSON-LD blocks (safe to run on rebuild) --
229
+
230
+ html = html.replace(/\n\s*<!-- Open Graph -->[\s\S]*?<\/script>\s*/g, '\n ')
231
+
232
+ // -- Open Graph + Twitter Card -----------------------------------------------
233
+
234
+ // Only include image tags if ogImage is provided
235
+ const imageMeta = ogImage ? `
236
+ <meta property="og:image" content="${ogImageSafe}" />
237
+ <meta name="twitter:image" content="${ogImageSafe}" />` : ''
238
+
239
+ const ogTags = `
240
+ <!-- Open Graph -->
241
+ <meta property="og:type" content="website" />
242
+ <meta property="og:url" content="${canonicalSafe}" />
243
+ <meta property="og:title" content="${titleSafe}" />
244
+ <meta property="og:description" content="${descriptionSafe}" />${imageMeta}
245
+ <meta property="og:locale" content="en_US" />
246
+ <meta property="og:site_name" content="${siteNameSafe}" />
247
+
248
+ <!-- Twitter / X Card -->
249
+ <meta name="twitter:card" content="summary_large_image" />
250
+ <meta name="twitter:title" content="${titleSafe}" />
251
+ <meta name="twitter:description" content="${descriptionSafe}" />`
252
+
253
+ // -- JSON-LD ----------------------------------------------------------------
254
+
255
+ let jsonLdBlock = ''
256
+ if (typeof buildJsonLd === 'function') {
257
+ const jsonLdData = buildJsonLd.call(config)
258
+ // Documented contract (ssr.config.example.js): accept a single bare
259
+ // object OR an array of objects. The array-only check used to silently
260
+ // drop the most common shape a consumer writes, while the config example
261
+ // promised object support -- caught in the crashtest vendored-copy diff.
262
+ const payload = Array.isArray(jsonLdData)
263
+ ? (jsonLdData.length === 0
264
+ ? null
265
+ : jsonLdData.length === 1
266
+ ? jsonLdData[0]
267
+ : { '@context': 'https://schema.org', '@graph': jsonLdData })
268
+ : (jsonLdData && typeof jsonLdData === 'object' ? jsonLdData : null)
269
+ if (payload) {
270
+ jsonLdBlock = `
271
+ <!-- JSON-LD Structured Data -- generated from ssr.config.js -->
272
+ <script type="application/ld+json">
273
+ ${JSON.stringify(payload, null, 2).replace(/</g, '\\u003c')}
274
+ </script>`
275
+ }
276
+ }
277
+
278
+ const jsonLdBlockSafe = toReplaceSafe(jsonLdBlock)
279
+
280
+ html = html.replace('</head>', `${ogTags}\n${jsonLdBlockSafe}\n </head>`)
281
+
282
+ writeFileSync(htmlPath, html, 'utf8')
283
+
284
+ console.log('[inject-brand] injection complete')
285
+ console.log(` Title: ${title}`)
286
+ console.log(` Canonical: ${canonical}`)
287
+
288
+ // -- CSP update for proxy origin --------------------------------------------
289
+ // Only runs when config.proxy.url is set.
290
+ // Appends the proxy origin to the connect-src directive in dist/_headers.
291
+ // Safe to run multiple times -- checks for existing entry before appending.
292
+
293
+ const headersPath = join(distDir, '_headers')
294
+
295
+ if (proxy?.url && existsSync(headersPath)) {
296
+ try {
297
+ const proxyOrigin = new URL(proxy.url).origin
298
+
299
+ let headers = readFileSync(headersPath, 'utf8')
300
+
301
+ // Match the full connect-src value up to (but not including) the semicolon
302
+ const connectSrcRegex = /(Content-Security-Policy:[^\n]*connect-src\s+)([^;]+)/
303
+
304
+ const match = headers.match(connectSrcRegex)
305
+ if (!match) {
306
+ console.warn('[inject-brand] connect-src not found in _headers -- skipping CSP update')
307
+ } else {
308
+ const existing = match[2]
309
+ if (existing.includes(proxyOrigin)) {
310
+ console.log(`[inject-brand] CSP already contains proxy origin (${proxyOrigin}) -- skipping`)
311
+ } else {
312
+ // Append the proxy origin cleanly, trimming any trailing whitespace first
313
+ headers = headers.replace(connectSrcRegex, `$1${existing.trimEnd()} ${proxyOrigin}`)
314
+ writeFileSync(headersPath, headers, 'utf8')
315
+ console.log(`[inject-brand] CSP connect-src updated with proxy origin: ${proxyOrigin}`)
316
+ }
317
+ }
318
+ } catch (err) {
319
+ // A bad proxyUrl is a config mistake, not a build-blocker
320
+ console.warn('[inject-brand] CSP update failed:', err.message)
321
+ }
322
+ }
323
+
324
+ // -- Worker bot list sync --------------------------------------------------
325
+ // The VPS proxy (scripts/proxy.js) reads config.proxy.botList from
326
+ // ssr.config.js at startup, but the Cloudflare Worker has no build-time
327
+ // config access -- its list is embedded in scripts/proxy.worker.js.
328
+ // Embed it here so a botList change only ever means editing ssr.config.js
329
+ // and rebuilding. The file may legitimately not exist (proxy is optional,
330
+ // example/ has none) -- skip silently in that case.
331
+ syncProxyBotList(config, projectRoot || dirname(resolve(distDir)))
332
+ }
333
+
334
+ // Replaces the BOT_LIST array literal inside scripts/proxy.worker.js with
335
+ // config.proxy.botList. Idempotent: no write when the content is identical.
336
+ // Returns true if the file was found and is in sync (written or already equal).
337
+ /**
338
+ * Rewrite config.proxy.botList entries into the wire files that ship so the
339
+ * scaffolded VPS/Worker proxies stay in parity with the config.
340
+ *
341
+ * @param {object} config - full ssr.config.js export
342
+ * @param {string} projectRoot - project root containing the wire files
343
+ * @returns {void}
344
+ */
345
+ export function syncProxyBotList(config, projectRoot) {
346
+ const proxy = (config && config.proxy) || {}
347
+ const botList = proxy.botList
348
+ if (!proxy.url || !Array.isArray(botList) || botList.length === 0) return false
349
+
350
+ const workerPath = join(projectRoot, 'scripts', 'proxy.worker.js')
351
+ if (!existsSync(workerPath)) return false
352
+
353
+ try {
354
+ const worker = readFileSync(workerPath, 'utf8')
355
+ const tokensSafe = botList
356
+ .map(t => String(t).replace(/\\/g, '\\\\').replace(/'/g, "\\'"))
357
+ .map(t => ` '${t}',`)
358
+ .join('\n')
359
+ const replacement = `const BOT_LIST = [\n${tokensSafe}\n]`
360
+
361
+ // Match the BOT_LIST declaration up to its closing bracket line. The
362
+ // array only ever contains single-quoted token lines, so \n] is the end.
363
+ const regex = /const BOT_LIST = \[[\s\S]*?\n\]/
364
+ if (!regex.test(worker)) {
365
+ console.warn('[inject-brand] BOT_LIST array not found in scripts/proxy.worker.js -- skipping sync')
366
+ return false
367
+ }
368
+
369
+ // Function form replacer: a string second-arg would expand "$&"-shaped
370
+ // patterns inside bot tokens (replace() special sequences). A function
371
+ // replacer treats the computed replacement literally.
372
+ const next = worker.replace(regex, () => replacement)
373
+ if (next !== worker) {
374
+ writeFileSync(workerPath, next, 'utf8')
375
+ console.log(`[inject-brand] BOT_LIST synced from ssr.config.js (${botList.length} bots) -> scripts/proxy.worker.js`)
376
+ } else {
377
+ console.log('[inject-brand] BOT_LIST already in sync with ssr.config.js')
378
+ }
379
+ return true
380
+ } catch (err) {
381
+ // A mangled worker file is a manual-edit problem, not a build-blocker
382
+ console.warn('[inject-brand] Worker bot list sync failed:', err.message)
383
+ return false
384
+ }
385
+ }
386
+
387
+ // -- CLI entry ----------------------------------------------------------------
388
+ // Only runs when executed directly (node scripts/inject-brand.js) -- never on import.
389
+
390
+ /**
391
+ * True when this module was executed directly (node scripts/inject-brand.js);
392
+ * library importers never trigger the CLI wrapper.
393
+ *
394
+ * @returns {boolean}
395
+ */
396
+ export function isMainExecute() {
397
+ try {
398
+ return import.meta.url === pathToFileURL(process.argv[1] || '').href
399
+ } catch {
400
+ return false
401
+ }
402
+ }
403
+
404
+ if (isMainExecute()) {
405
+ (async () => {
406
+ let config
407
+ try {
408
+ config = (await import('../ssr.config.js')).default
409
+ } catch (err) {
410
+ console.warn('[inject-brand] Could not load ssr.config.js -- skipping:', err.message)
411
+ process.exit(0)
412
+ }
413
+
414
+ // Validate required siteUrl
415
+ const { ok, errors } = validateConfig(config)
416
+ if (!ok) {
417
+ formatDiagnostics(errors).forEach(line => console.error(line))
418
+ if (process.env.PRESTRUCT_STRICT === '1') {
419
+ console.error('[inject-brand] PRESTRUCT_STRICT=1 -- failing build on config diagnostics')
420
+ process.exit(1)
421
+ }
422
+ }
423
+ if (!config.siteUrl) {
424
+ console.warn('[inject-brand] siteUrl is required in ssr.config.js -- skipping')
425
+ process.exit(0)
426
+ }
427
+
428
+ injectBrand(config, resolve(process.env.PRESTRUCT_DIST || 'dist'))
429
+ })()
430
+ }