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.
@@ -0,0 +1,93 @@
1
+ // engine/scripts/hooks.js - general postBuild hook contract (prestruct#41,
2
+ // port of the freestruct tag reference; prestruct#21's shape). Replaces the
3
+ // npm-script chain ("prestruct build && node scripts/whatever.mjs") with a
4
+ // first-class seam:
5
+ //
6
+ // // ssr.config.js
7
+ // hooks: { postBuild: ['node scripts/inject-faq-schema.mjs'] }
8
+ // runHooks: true, // opt-in: nothing executes without it
9
+ //
10
+ // Security posture (why the gate): postBuild entries are shell commands
11
+ // sourced from the config file. A malicious or typo'd config must not gain
12
+ // a silent execution path, so nothing runs unless runHooks is explicitly
13
+ // true - logged loudly either way. The build pipeline runs the hooks
14
+ // itself, so `prestruct build` can no longer skip them the way it skipped
15
+ // the npm-script chain.
16
+ //
17
+ // Values ride FUNCTION replacers, never string-form replace strings: an
18
+ // output path carrying $& or $1..9 is replacement SYNTAX in string form
19
+ // and would corrupt the command about to run (the same never-corrupt rule
20
+ // the injection pipeline enforces on head values).
21
+ //
22
+ // This module NEVER exits: it returns the failure list; bin/cli.mjs turns
23
+ // the collection into the build exit.
24
+
25
+ import { execSync } from 'child_process'
26
+
27
+ /** Build a fresh env for hook children: same facts as the placeholders, so
28
+ * a hook script does not do string surgery to learn where the build landed. */
29
+ function hookEnv({ siteUrl, outputDir }) {
30
+ return {
31
+ ...process.env,
32
+ PRESTRUCT_SITE_URL: siteUrl,
33
+ PRESTRUCT_OUTPUT_DIR: outputDir,
34
+ }
35
+ }
36
+
37
+ function substitutePlaceholders(command, { siteUrl, outputDir }) {
38
+ return command
39
+ .replace(/\$\{?SITE_URL\}?/g, function () { return siteUrl })
40
+ .replace(/\$\{?OUTPUT_DIR\}?/g, function () { return outputDir })
41
+ }
42
+
43
+ /**
44
+ * Run config.hooks.postBuild (string | string[]): each entry spawned with
45
+ * stdio inherited and a 30-second timeout (the family reference posture:
46
+ * a hung hook may cost the build its runtime, never the deploy); a hook
47
+ * that genuinely needs longer can background its own work. PRESTRUCT_SITE_URL
48
+ * and PRESTRUCT_OUTPUT_DIR are in the env, placeholders substituted via
49
+ * function replacers. Returns the list of failed entries (empty = clean).
50
+ */
51
+ export function runPostBuildHooks(config, { siteUrl, outputDir } = {}) {
52
+ const raw = config?.hooks?.postBuild
53
+ if (raw === undefined || raw === null) return []
54
+ const commands = Array.isArray(raw) ? raw : [raw]
55
+ if (commands.length === 0) return []
56
+
57
+ const url = siteUrl ?? config.siteUrl ?? ''
58
+ const out = outputDir ?? 'dist'
59
+ const failures = []
60
+ let gated = false
61
+
62
+ for (const entry of commands) {
63
+ if (typeof entry !== 'string' || entry.trim() === '') {
64
+ console.log('[prestruct] hooks.postBuild entry is not a non-empty string command - skipped')
65
+ continue
66
+ }
67
+ const command = substitutePlaceholders(entry, { siteUrl: url, outputDir: out })
68
+
69
+ if (config.runHooks !== true) {
70
+ console.log('[prestruct] PostBuild hook (not run - set runHooks: true to enable): '
71
+ + command.slice(0, 100))
72
+ gated = true
73
+ continue
74
+ }
75
+
76
+ console.log('[prestruct] PostBuild hook: running ' + command.slice(0, 76) + '...')
77
+ try {
78
+ execSync(command, { stdio: 'inherit', timeout: 30 * 1000, env: hookEnv({ siteUrl: url, outputDir: out }) })
79
+ console.log('[prestruct] PostBuild hook: done')
80
+ } catch (e) {
81
+ console.log('[prestruct] PostBuild hook: FAILED (exit ' + (e.status ?? e.message) + ')')
82
+ // Timeout kills surface as a signal, not an exit code - record it so a
83
+ // hung hook is distinguishable from a clean nonzero exit.
84
+ failures.push({ command: entry, status: e.status, signal: e.signal })
85
+ }
86
+ }
87
+
88
+ if (gated && failures.length === 0) {
89
+ console.log('[prestruct] hooks.postBuild is configured but no hook ran '
90
+ + '(opt-in gate: set runHooks: true in ssr.config.js)')
91
+ }
92
+ return failures
93
+ }
@@ -0,0 +1,139 @@
1
+ // engine/scripts/indexnow.js - IndexNow key-file emission + submission
2
+ // (prestruct#36 port of the freestruct tag reference; prestruct owns the
3
+ // sitemap in prerender.js, so the fit is the same: key file into dist/ =
4
+ // the keyLocation contract, a `prestruct indexnow` verb that reads the
5
+ // BAKED sitemap.xml (the source of truth for what is actually live, not
6
+ // config) and POSTs the URL list. Honesty contract: submission only makes
7
+ // sense AFTER the output is deployed live (keyLocation must resolve at
8
+ // host/key.txt) - docs say so; the verb surfaces HTTP status codes; the
9
+ // --dry-run flag prints the request body without touching the network.
10
+ // This module NEVER exits: exit codes belong to bin/cli.mjs, same as audit.
11
+
12
+ import fs from 'fs'
13
+ import crypto from 'crypto'
14
+ import path from 'path'
15
+
16
+ export const INDEXNOW_API = 'https://api.indexnow.org/indexnow'
17
+
18
+ /** 32 lowercase hex chars is the documented IndexNow key shape. */
19
+ export function isValidIndexNowKey(key) {
20
+ return typeof key === 'string' && /^[a-f0-9]{32}$/.test(key)
21
+ }
22
+
23
+ /**
24
+ * Resolve the IndexNow key for a run.
25
+ * indexNow.key (config, 32-hex) wins
26
+ * indexNow: true reads configDir/indexnow-key.txt, or
27
+ * generates one and PERSISTS it
28
+ * (generate-once: the same key across
29
+ * runs keeps the keyLocation stable at
30
+ * the search engines)
31
+ * Rotating? Change the config key or delete indexnow-key.txt - loudly
32
+ * logged. Returns { key, source } or null when IndexNow is not configured.
33
+ */
34
+ export function resolveIndexNowKey(config, configDir) {
35
+ const cfg = config.indexNow
36
+ if (cfg === undefined || cfg === null || cfg === false) return null
37
+ if (typeof cfg === 'string' || typeof cfg === 'object') {
38
+ const key = typeof cfg === 'string' ? cfg : cfg.key
39
+ if (key !== undefined && key !== null) {
40
+ if (!isValidIndexNowKey(key)) return null // config diagnostics already warned
41
+ return { key, source: 'config' }
42
+ }
43
+ }
44
+ const persistPath = path.join(configDir, 'indexnow-key.txt')
45
+ if (fs.existsSync(persistPath)) {
46
+ const key = fs.readFileSync(persistPath, 'utf8').trim()
47
+ if (isValidIndexNowKey(key)) return { key, source: 'persisted (indexnow-key.txt)' }
48
+ }
49
+ const key = crypto.randomBytes(16).toString('hex')
50
+ fs.mkdirSync(configDir, { recursive: true })
51
+ fs.writeFileSync(persistPath, key + '\n', 'utf8')
52
+ console.log('[prestruct] indexnow: generated a new key and persisted it to '
53
+ + persistPath
54
+ + ' (generate-once: commit this file so the keyLocation stays stable across runs)')
55
+ return { key, source: 'generated' }
56
+ }
57
+
58
+ /** Emit <key>.txt into the output dir (the keyLocation file engines probe). */
59
+ export function emitIndexNowKeyFile(key, outputDir) {
60
+ const keyFile = path.join(outputDir, key + '.txt')
61
+ fs.writeFileSync(keyFile, key + '\n', 'utf8')
62
+ console.log('[prestruct] indexnow: key file written: ' + key
63
+ + '.txt (deploy it live - keyLocation must resolve)')
64
+ return keyFile
65
+ }
66
+
67
+ /** Read the baked sitemap.xml and return its URL list (null when absent/empty). */
68
+ export function readSitemapUrls(outputDir) {
69
+ const sitemapPath = path.join(outputDir, 'sitemap.xml')
70
+ if (!fs.existsSync(sitemapPath)) return null
71
+ const sitemap = fs.readFileSync(sitemapPath, 'utf8')
72
+ const urls = (sitemap.match(/<loc>([\s\S]*?)<\/loc>/g) || [])
73
+ .map(function (e) { return e.replace(/<\/?loc>/g, '').trim() })
74
+ .filter(Boolean)
75
+ return urls.length > 0 ? urls : null
76
+ }
77
+
78
+ /**
79
+ * POST the sitemap URL list to api.indexnow.org. Returns the HTTP status so
80
+ * the caller can surface it (202 = accepted). dryRun skips the network and
81
+ * prints the request body instead - the CI-safe shape.
82
+ */
83
+ export async function submitIndexNow({ config, configDir, outputDir, dryRun }) {
84
+ const resolved = resolveIndexNowKey(config, configDir)
85
+ if (!resolved) {
86
+ console.log('[prestruct] indexnow: not configured (set indexNow in ssr.config.js)')
87
+ return { ok: false, status: null, reason: 'not-configured' }
88
+ }
89
+
90
+ const urls = readSitemapUrls(outputDir)
91
+ if (!urls) {
92
+ console.log('[prestruct] indexnow: no sitemap.xml with <loc> entries in '
93
+ + outputDir + ' - run prestruct build first (indexnow reads the baked sitemap)')
94
+ return { ok: false, status: null, reason: 'no-sitemap' }
95
+ }
96
+
97
+ let host
98
+ try { host = new URL(config.siteUrl).host } catch {
99
+ console.log('[prestruct] indexnow: siteUrl is not parseable - cannot derive host')
100
+ return { ok: false, status: null, reason: 'bad-site-url' }
101
+ }
102
+
103
+ const payload = {
104
+ host,
105
+ key: resolved.key,
106
+ urlList: urls,
107
+ keyLocation: config.siteUrl.replace(/\/$/, '') + '/' + resolved.key + '.txt',
108
+ }
109
+
110
+ console.log('[prestruct] indexnow: submitting ' + urls.length + ' URL(s) to '
111
+ + INDEXNOW_API + ' (key source: ' + resolved.source + ')')
112
+ if (dryRun) {
113
+ console.log('[prestruct] indexnow: --dry-run, request body:')
114
+ console.log(JSON.stringify(payload, null, 2))
115
+ return { ok: true, status: 'dry-run', reason: 'dry-run' }
116
+ }
117
+
118
+ let res
119
+ try {
120
+ res = await fetch(INDEXNOW_API, {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
123
+ body: JSON.stringify(payload),
124
+ })
125
+ } catch (e) {
126
+ console.log('[prestruct] indexnow: request failed: ' + e.message)
127
+ return { ok: false, status: null, reason: 'network-error' }
128
+ }
129
+
130
+ console.log('[prestruct] indexnow: HTTP ' + res.status
131
+ + (res.status === 200 || res.status === 202
132
+ ? ' (accepted)'
133
+ : ' (NOT accepted - the engines surface failures; check the key file is deployed live)'))
134
+ if (res.status !== 200 && res.status !== 202) {
135
+ const body = await res.text().catch(function () { return '' })
136
+ if (body) console.log('[prestruct] indexnow: response: ' + body.slice(0, 300))
137
+ }
138
+ return { ok: res.status === 200 || res.status === 202, status: res.status, reason: 'http' }
139
+ }
@@ -127,6 +127,35 @@ function defaultGetDeployId() {
127
127
  * never exits.
128
128
  *
129
129
  * @param {object} config - full ssr.config.js export
130
+ /**
131
+ * Config-driven JSON-LD defaults (#30): Organization + WebSite, built from
132
+ * the same siteName/siteUrl/author keys the head already consumes. A host
133
+ * that never defines buildJsonLd still ships both blocks. Exported for the
134
+ * battery: the shape is a contract, not an implementation detail.
135
+ *
136
+ * @param {{siteUrl?: string, siteName?: string, author?: string}} config
137
+ * @returns {object|null} wrapped payload ({@graph}) or null when nothing
138
+ * is derivable (no siteUrl AND no siteName/author).
139
+ */
140
+ export function buildDefaultJsonLd({ siteUrl, siteName, author } = {}) {
141
+ const base = siteUrl ? siteUrl.replace(/\/$/, '') : ''
142
+ const nodes = []
143
+ if (siteName || author || base) {
144
+ const org = { '@type': 'Organization' }
145
+ if (siteName || author) org.name = siteName || author
146
+ if (base) org.url = base
147
+ nodes.push(org)
148
+ }
149
+ // WebSite is only emitted with BOTH identity facts: a name-less or
150
+ // URL-less WebSite node is schema-invalid, not "shorter defaults".
151
+ if (siteName && base) {
152
+ nodes.push({ '@type': 'WebSite', name: siteName, url: base })
153
+ }
154
+ if (nodes.length === 0) return null
155
+ return { '@context': 'https://schema.org', '@graph': nodes }
156
+ }
157
+
158
+ /**
130
159
  * @param {string} [distDir=resolve('dist')] - build output directory
131
160
  * @param {{getDeployId?: Function, projectRoot?: string}} [opts] - injection hooks
132
161
  * @returns {void}
@@ -135,7 +164,7 @@ export function injectBrand(config, distDir = resolve('dist'), { getDeployId = d
135
164
  const {
136
165
  platform = 'cloudflare',
137
166
  siteUrl, siteName, author, tagline, ogImage, keywords,
138
- routes = [], buildJsonLd, proxy = {},
167
+ routes = [], buildJsonLd, proxy = {}, jsonLdDefaults,
139
168
  } = config
140
169
 
141
170
  // Only generate Cloudflare-specific files (_headers, _redirects)
@@ -148,24 +177,63 @@ export function injectBrand(config, distDir = resolve('dist'), { getDeployId = d
148
177
  const htmlPath = join(distDir, 'index.html')
149
178
 
150
179
  if (!existsSync(htmlPath)) {
180
+ // Library-safe (matches this function's contract): RETURN, never
181
+ // process.exit -- the CLI calls injectBrand in-process, so exiting here
182
+ // would kill the whole build silently with code 0, a green fail-open
183
+ // right where a missing shell should be a hard error. The direct-run
184
+ // wrapper (isMainExecute) turns the null return into exit 0 for the
185
+ // legacy standalone path.
151
186
  console.warn('[inject-brand] dist/index.html not found -- skipping')
152
- process.exit(0)
187
+ return null
153
188
  }
154
189
 
155
190
  // Homepage meta as the global description fallback
156
191
  const homeRoute = routes.find(r => r.path === '/') || {}
157
192
  const title = generateTitle(siteName, tagline)
158
193
  const description = homeRoute.meta?.description || null
159
- const canonical = `${siteUrl}/`
194
+ // Trailing-slash normalization (#53): the canonical + entire og block ride
195
+ // this base; a consumer pasting `https://x.example.com/` used to get
196
+ // `https://x.example.com//` -- validated form, doubled junction.
197
+ // Home canonical is the BARE origin, no trailing slash (#76): the same form
198
+ // prerender.injectMeta and generateSitemap use for `/`, so the global head
199
+ // inject-brand writes agrees with every per-route surface (the 404 used to
200
+ // be the lone outlier carrying `siteUrl/`).
201
+ const canonical = (siteUrl || '').replace(/\/$/, '')
160
202
 
161
203
  const titleSafe = toReplaceSafe(escapeHtmlAttr(title))
162
204
  const descriptionSafe = toReplaceSafe(escapeHtmlAttr(description))
163
205
  const authorSafe = toReplaceSafe(escapeHtmlAttr(author))
164
206
  const keywordsSafe = toReplaceSafe(escapeHtmlAttr(keywords))
165
207
  const canonicalSafe = toReplaceSafe(escapeHtmlAttr(canonical))
166
- const ogImageSafe = toReplaceSafe(escapeHtmlAttr(ogImage))
167
208
  const siteNameSafe = toReplaceSafe(escapeHtmlAttr(siteName))
168
209
 
210
+ // og:image accepts the legacy string shorthand AND the structured object
211
+ // form (object contract per validate-config): { url, width, height, alt,
212
+ // type }. Companion properties are emitted ONLY when present and only
213
+ // when their value validates at this seam, so a typo'd companion can
214
+ // never corrupt the head (same never-corrupt rule as the escape pipeline).
215
+ const ogImgObject = (ogImage && typeof ogImage === 'object' && !Array.isArray(ogImage))
216
+ ? ogImage
217
+ : (ogImage ? { url: String(ogImage) } : null)
218
+ const ogImgUrl = typeof ogImgObject?.url === 'string' ? ogImgObject.url : ''
219
+ const ogImgWidth = /^[0-9]+$/.test(String(ogImgObject?.width ?? '')) ? String(ogImgObject.width) : ''
220
+ const ogImgHeight = /^[0-9]+$/.test(String(ogImgObject?.height ?? '')) ? String(ogImgObject.height) : ''
221
+ const ogImgAlt = typeof ogImgObject?.alt === 'string' ? ogImgObject.alt : ''
222
+ const ogImgType = typeof ogImgObject?.type === 'string' ? ogImgObject.type : ''
223
+
224
+ const ogImageSafe = toReplaceSafe(escapeHtmlAttr(ogImgUrl))
225
+ const ogImgWidthSafe = toReplaceSafe(escapeHtmlAttr(ogImgWidth))
226
+ const ogImgHeightSafe = toReplaceSafe(escapeHtmlAttr(ogImgHeight))
227
+ const ogImgAltSafe = toReplaceSafe(escapeHtmlAttr(ogImgAlt))
228
+ const ogImgTypeSafe = toReplaceSafe(escapeHtmlAttr(ogImgType))
229
+ // og:image:secure_url is emitted only for http:// sources, as the upgraded
230
+ // https variant. An already-https source makes the property redundant
231
+ // (og:image itself is the secure form) -- emitting it there just doubles
232
+ // the same URL in the head.
233
+ const ogImgSecureSafe = ogImgUrl.startsWith('http://')
234
+ ? toReplaceSafe(escapeHtmlAttr('https://' + ogImgUrl.slice('http://'.length)))
235
+ : ''
236
+
169
237
  let html = readFileSync(htmlPath, 'utf8')
170
238
 
171
239
  // -- Deploy ID --------------------------------------------------------------
@@ -193,7 +261,12 @@ export function injectBrand(config, distDir = resolve('dist'), { getDeployId = d
193
261
  // stripped when not (safe to run on rebuild either way). It travels on the
194
262
  // root div and is carried through prerendering by replaceRootDiv.
195
263
  if (showFooter) {
196
- html = html.replace(/<div id="root"/, '<div id="root" data-deploy-id-footer')
264
+ // Insert after the root div's `id="root"` wherever it sits -- the old
265
+ // byte-exact `/<div id="root"/` missed a reflowed shell (single quotes,
266
+ // spaces around `=`, or an attribute before the id), silently dropping the
267
+ // opt-in footer attribute the config asked for with no warning. Match the
268
+ // same attribute-tolerant span the rest of the engine uses.
269
+ html = html.replace(/(<div\b[^>]*\sid\s*=\s*["']root["'])/, '$1 data-deploy-id-footer')
197
270
  } else {
198
271
  html = html.replace(/ data-deploy-id-footer/g, '')
199
272
  }
@@ -226,15 +299,30 @@ export function injectBrand(config, distDir = resolve('dist'), { getDeployId = d
226
299
  `<link rel="canonical" href="${canonicalSafe}" />`)
227
300
 
228
301
  // -- 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 ')
302
+ // Bounded at </head>: the old `[\s\S]*?<\/script>` lazily matched the block's
303
+ // own closing <script>, but when inject-brand emitted NO JSON-LD (buildJsonLd
304
+ // returned nothing, or jsonLdDefaults:false) there was no engine <script> to
305
+ // stop at -- the match ran past </head> into <body> and ate the root div and
306
+ // the app entry script on the SECOND run (docs promise idempotence). The
307
+ // injected block always sits immediately before </head>, so anchor there.
308
+ html = html.replace(/\n\s*<!-- Open Graph -->[\s\S]*?(?=\n\s*<\/head>)/g, '')
231
309
 
232
310
  // -- Open Graph + Twitter Card -----------------------------------------------
233
311
 
234
- // Only include image tags if ogImage is provided
235
- const imageMeta = ogImage ? `
312
+ // Only include image tags if an og:image url resolves (string shorthand or
313
+ // object form). Companion properties ride below the pair so inject-brand
314
+ // output stays one copy-pasted block.
315
+ const imageCompanions = [
316
+ ogImgSecureSafe ? ` <meta property="og:image:secure_url" content="${ogImgSecureSafe}" />` : '',
317
+ ogImgTypeSafe ? ` <meta property="og:image:type" content="${ogImgTypeSafe}" />` : '',
318
+ ogImgWidthSafe ? ` <meta property="og:image:width" content="${ogImgWidthSafe}" />` : '',
319
+ ogImgHeightSafe ? ` <meta property="og:image:height" content="${ogImgHeightSafe}" />` : '',
320
+ ogImgAltSafe ? ` <meta property="og:image:alt" content="${ogImgAltSafe}" />` : '',
321
+ ].filter(Boolean).join('\n')
322
+
323
+ const imageMeta = ogImgUrl ? `
236
324
  <meta property="og:image" content="${ogImageSafe}" />
237
- <meta name="twitter:image" content="${ogImageSafe}" />` : ''
325
+ <meta name="twitter:image" content="${ogImageSafe}" />${imageCompanions ? '\n' + imageCompanions : ''}` : ''
238
326
 
239
327
  const ogTags = `
240
328
  <!-- Open Graph -->
@@ -273,6 +361,20 @@ export function injectBrand(config, distDir = resolve('dist'), { getDeployId = d
273
361
  ${JSON.stringify(payload, null, 2).replace(/</g, '\\u003c')}
274
362
  </script>`
275
363
  }
364
+ } else if (jsonLdDefaults !== false) {
365
+ // Config-driven defaults (#30): a host that never defines buildJsonLd
366
+ // used to ship zero structured data even though config already carries
367
+ // siteName/siteUrl/author - the exact data Organization/WebSite need.
368
+ // Defaults emit those two nodes; jsonLdDefaults: false opts out. The
369
+ // payload walks the same object/array wrapper as the host-provided form.
370
+ const defaults = buildDefaultJsonLd({ siteUrl, siteName, author })
371
+ if (defaults) {
372
+ jsonLdBlock = `
373
+ <!-- JSON-LD Structured Data -- engine defaults (set jsonLdDefaults: false in ssr.config.js to skip) -->
374
+ <script type="application/ld+json">
375
+ ${JSON.stringify(defaults, null, 2).replace(/</g, '\\u003c')}
376
+ </script>`
377
+ }
276
378
  }
277
379
 
278
380
  const jsonLdBlockSafe = toReplaceSafe(jsonLdBlock)
@@ -0,0 +1,165 @@
1
+ // engine/scripts/ogimage.js - per-route OG image generation (prestruct#43,
2
+ // port of the freestruct tag reference; the prestruct route set + per-route
3
+ // meta titles/descriptions already live in prerender.js, so this reads the
4
+ // same facts). Read each route's title/description, render a branded
5
+ // 1200x630 PNG per route from a consumer-authored SVG template, write the
6
+ // file into dist/og/, and wire the og/twitter tags to it (missing-only:
7
+ // a route with its own og:image override keeps theirs).
8
+ //
9
+ // Rasterization honesty (the pitch's own hard part): PNG cannot be made in
10
+ // pure Node. The rasterizer is the OPTIONAL npm package @resvg/resvg-js -
11
+ // loaded lazily with createRequire and skipped loudly when it is not
12
+ // installed (the run never breaks, never ships a half feature). Pure-Node
13
+ // output shape: SVG rendering is deterministic (same route meta ->
14
+ // same bytes). Fonts: system fonts are unreliable in build sandboxes, so
15
+ // ogImageGeneration.font loads a TTF/OTF buffer straight into the raster.
16
+ //
17
+ // Config shape (configDir-relative paths):
18
+ // ogImageGeneration:
19
+ // template: og-template.svg # 1200x630 SVG with {{ogTitle}} /
20
+ // # {{ogDescription}} / {{ogUrl}}
21
+ // font: ./brand/brand-bold.ttf # optional
22
+ // width: 1200 # optional
23
+ // height: 630 # optional
24
+ //
25
+ // This module NEVER exits: callers own failure semantics (prerender skips
26
+ // loudly, the audit still grades the head).
27
+
28
+ import fs from 'fs'
29
+ import path from 'path'
30
+ import { createRequire } from 'module'
31
+
32
+ const _require = createRequire(import.meta.url)
33
+
34
+ /**
35
+ * Local XML escape for template substitution values: same replacement set
36
+ * as prerender's escapeXml, kept local so this module stays dependency-free
37
+ * (prerender owns no ogimage concerns and importing it back would be a cycle).
38
+ */
39
+ function escXml(value) {
40
+ return String(value ?? '')
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&apos;')
46
+ }
47
+
48
+ /** Directory slug for the image filename: '/' and nested paths flatten.
49
+ * Exported so the caller can detect slug collisions between routes. */
50
+ export function imageSlug(routePath) {
51
+ return String(routePath).replace(/^\/+|\/+$/g, '').replace(/\//g, '-') || 'index'
52
+ }
53
+
54
+ /** Lazily resolve the optional rasterizer CLASS; null when not installed. */
55
+ function resolveRasterizer() {
56
+ try {
57
+ const mod = _require('@resvg/resvg-js')
58
+ // CJS shape: { default?: Resvg, Resvg } ; ESM default handled too.
59
+ const ctor = mod.Resvg || mod.default || mod
60
+ return (typeof ctor === 'function') ? ctor : null
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Validate + resolve the generation block. Returns null when it is not
68
+ * configured; every disqualifying shape logs a loud single warning (config
69
+ * diagnostics already handled key-name mistakes).
70
+ */
71
+ export function resolveOgImageGeneration(config, configDir) {
72
+ const gen = config && config.ogImageGeneration
73
+ if (gen === undefined || gen === null || gen === false) return null
74
+ if (typeof gen !== 'object' || Array.isArray(gen)) {
75
+ console.log('[ogimage] config warning: ogImageGeneration must be a { template, font?, width?, height? } object')
76
+ return null
77
+ }
78
+ if (typeof gen.template !== 'string' || gen.template === '') {
79
+ console.log('[ogimage] config warning: ogImageGeneration.template missing - no og image generation')
80
+ return null
81
+ }
82
+ const templatePath = path.resolve(configDir, gen.template)
83
+ if (!fs.existsSync(templatePath)) {
84
+ console.log('[ogimage] config warning: ogImageGeneration.template "'
85
+ + gen.template + '" not found at ' + templatePath + ' - no og image generation')
86
+ return null
87
+ }
88
+ const width = Number(gen.width ?? 1200)
89
+ const height = Number(gen.height ?? 630)
90
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
91
+ console.log('[ogimage] config warning: ogImageGeneration width/height must be positive numbers - no og image generation')
92
+ return null
93
+ }
94
+ const fontPath = gen.font ? path.resolve(configDir, gen.font) : null
95
+ if (gen.font && (!fontPath || !fs.existsSync(fontPath))) {
96
+ console.log('[ogimage] config warning: ogImageGeneration.font "'
97
+ + gen.font + '" not found at ' + fontPath + ' - the rasterizer will fall back to system fonts (unreliable in build sandboxes)')
98
+ return null
99
+ }
100
+ const template = fs.readFileSync(templatePath, 'utf8')
101
+ const fontBytes = fontPath ? fs.readFileSync(fontPath) : null
102
+ if (/<text/i.test(template) && !fontBytes) {
103
+ console.log('[ogimage] warning: template carries <text> but no font is configured - add ogImageGeneration.font so build sandboxes without system fonts do not drop titles')
104
+ }
105
+ return { template, templatePath, fontPath, fontBytes, width, height }
106
+ }
107
+
108
+ /**
109
+ * Render one per-route image into dist/og/ and hand back the tag-ready
110
+ * facts. Returns null when the rasterizer is not installed (loud skip,
111
+ * surfaced once per run) or the SVG fails to rasterize. The og:url is
112
+ * template-fillable via {{ogUrl}}; the caller derives the absolute
113
+ * base from config.siteUrl.
114
+ *
115
+ * @param {object} generation - resolved block from resolveOgImageGeneration
116
+ * @param {{path: string, meta?: {title?: string, description?: string}}} route
117
+ * @param {string} siteUrl - configured site origin
118
+ * @param {string} outputDir - dist/
119
+ * @returns {{src: string, width: number, height: number, alt: string}|null}
120
+ */
121
+ export function renderRouteOgImage(generation, route, siteUrl, outputDir) {
122
+ const Resvg = resolveRasterizer()
123
+ if (!Resvg) {
124
+ if (!renderRouteOgImage._skippedLoudly) {
125
+ renderRouteOgImage._skippedLoudly = true
126
+ console.log('[ogimage] @resvg/resvg-js is not installed - generation skips loudly'
127
+ + ' (pure-Node cannot rasterize; install it with: npm install --save-dev @resvg/resvg-js)')
128
+ }
129
+ return null
130
+ }
131
+
132
+ const routePath = route && typeof route.path === 'string' ? route.path : '/'
133
+ const routeUrl = ((siteUrl || '').replace(/\/$/, '')) + (routePath === '/' ? '/' : routePath)
134
+ const displayTitle = (route.meta && route.meta.title) || routePath || 'Preview'
135
+ const svg = generation.template
136
+ .split('{{ogTitle}}').join(escXml(displayTitle))
137
+ .split('{{ogDescription}}').join(escXml((route.meta && route.meta.description) || ''))
138
+ .split('{{ogUrl}}').join(escXml(routeUrl))
139
+
140
+ const fontBuffers = generation.fontBytes ? [generation.fontBytes] : undefined
141
+ let png
142
+ try {
143
+ const rendered = new Resvg(svg, {
144
+ fitTo: { mode: 'width', value: generation.width },
145
+ font: fontBuffers ? { fontBuffers, loadSystemFonts: false } : { loadSystemFonts: false },
146
+ })
147
+ png = rendered.render().asPng()
148
+ } catch (e) {
149
+ console.log('[ogimage] route ' + routePath + ' failed to rasterize: ' + e.message)
150
+ return null
151
+ }
152
+
153
+ const name = imageSlug(routePath)
154
+ const genDir = path.join(outputDir, 'og')
155
+ if (!fs.existsSync(genDir)) fs.mkdirSync(genDir, { recursive: true })
156
+ const file = path.join(genDir, name + '.png')
157
+ fs.writeFileSync(file, png)
158
+
159
+ return {
160
+ src: '/og/' + name + '.png',
161
+ width: generation.width,
162
+ height: generation.height,
163
+ alt: displayTitle + ' - page preview image',
164
+ }
165
+ }