prestruct 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +159 -2
- package/bin/cli.mjs +212 -0
- package/engine/scripts/inject-brand.js +430 -0
- package/engine/scripts/prerender.js +726 -0
- package/engine/scripts/validate-config.js +117 -0
- package/engine/src/hooks/usePageMeta.js +78 -0
- package/engine/src/islands.js +129 -0
- package/engine/vite.config.js +18 -0
- package/init/VERSION +1 -0
- package/init/ecosystem.cjs +45 -0
- package/init/eslint.config.js +30 -0
- package/init/index.html +42 -0
- package/init/package-lock.json +3183 -0
- package/init/package.json +29 -0
- package/init/proxy.package.json +21 -0
- package/init/public/_headers +31 -0
- package/init/public/_redirects +16 -0
- package/init/scripts/inject-brand.js +430 -0
- package/init/scripts/prerender.js +726 -0
- package/init/scripts/prestruct-proxy.service +47 -0
- package/init/scripts/proxy.js +373 -0
- package/init/scripts/proxy.worker.js +217 -0
- package/init/scripts/validate-config.js +117 -0
- package/init/src/App.jsx +21 -0
- package/init/src/AppIslands.jsx +39 -0
- package/init/src/AppLayout.jsx +66 -0
- package/init/src/hooks/usePageMeta.js +78 -0
- package/init/src/islands.js +129 -0
- package/init/src/main.jsx +35 -0
- package/init/ssr.config.example.js +151 -0
- package/init/vite.config.js +18 -0
- package/init/vitest.config.js +14 -0
- package/init/wrangler.toml +32 -0
- package/package.json +70 -4
|
@@ -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, '&')
|
|
78
|
+
.replace(/</g, '<')
|
|
79
|
+
.replace(/>/g, '>')
|
|
80
|
+
.replace(/"/g, '"')
|
|
81
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|