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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
235
|
-
|
|
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, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|