mnfst-render 0.5.24 → 0.5.25
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 +1 -1
- package/manifest.render.mjs +255 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,4 +8,4 @@ Static renderer for Manifest projects.
|
|
|
8
8
|
npx mnfst-render --root .
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The command reads `manifest.json` and writes rendered pages to `manifest.
|
|
11
|
+
The command reads `manifest.json` and writes rendered pages to `manifest.render.output` (default `website`).
|
package/manifest.render.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { createServer } from 'node:http';
|
|
|
9
9
|
import { cpus } from 'node:os';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
@@ -213,18 +214,102 @@ function parseArgs() {
|
|
|
213
214
|
function loadConfig(rootDir) {
|
|
214
215
|
const manifestPath = join(rootDir, 'manifest.json');
|
|
215
216
|
if (!existsSync(manifestPath)) {
|
|
216
|
-
return {
|
|
217
|
+
return { render: {} };
|
|
217
218
|
}
|
|
218
219
|
const raw = readFileSync(manifestPath, 'utf8');
|
|
219
220
|
let manifest;
|
|
220
221
|
try {
|
|
221
222
|
manifest = JSON.parse(raw);
|
|
222
223
|
} catch {
|
|
223
|
-
return {
|
|
224
|
+
return { render: {} };
|
|
224
225
|
}
|
|
225
226
|
return manifest;
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
// Move credential-shaped values out of manifest.json into .env. Anything
|
|
230
|
+
// inlined into manifest.json ships verbatim into dist/ (the file is copied
|
|
231
|
+
// to the output unchanged), so a literal devKey there is a production leak.
|
|
232
|
+
// Rewriting source AND the in-memory object covers both directions: future
|
|
233
|
+
// renders see the placeholder, and the dist copy this run produces does too.
|
|
234
|
+
// Idempotent — values already shaped like ${VAR} are skipped.
|
|
235
|
+
function relocateSecretsToEnv(rootDir, manifest) {
|
|
236
|
+
const moves = [];
|
|
237
|
+
|
|
238
|
+
function maybeMove(obj, key, envVar, displayPath) {
|
|
239
|
+
if (!obj || typeof obj !== 'object') return;
|
|
240
|
+
const v = obj[key];
|
|
241
|
+
if (typeof v !== 'string' || !v) return;
|
|
242
|
+
if (/^\$\{[^}]+\}$/.test(v)) return; // already a placeholder
|
|
243
|
+
moves.push({ obj, key, value: v, envVar, displayPath });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
maybeMove(manifest.appwrite, 'devKey', 'APPWRITE_DEV_KEY', 'appwrite.devKey');
|
|
247
|
+
if (manifest.data && typeof manifest.data === 'object') {
|
|
248
|
+
for (const [srcName, src] of Object.entries(manifest.data)) {
|
|
249
|
+
maybeMove(src, 'appwriteDevKey', 'APPWRITE_DEV_KEY', `data.${srcName}.appwriteDevKey`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (moves.length === 0) return manifest;
|
|
254
|
+
|
|
255
|
+
for (const m of moves) {
|
|
256
|
+
m.obj[m.key] = `\${${m.envVar}}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const manifestPath = join(rootDir, 'manifest.json');
|
|
260
|
+
try {
|
|
261
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + '\n');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error(`mnfst-render: failed to rewrite ${manifestPath}: ${err.message}`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const envPath = join(rootDir, '.env');
|
|
268
|
+
let envText = '';
|
|
269
|
+
try {
|
|
270
|
+
if (existsSync(envPath)) envText = readFileSync(envPath, 'utf8');
|
|
271
|
+
} catch {}
|
|
272
|
+
if (envText && !envText.endsWith('\n')) envText += '\n';
|
|
273
|
+
|
|
274
|
+
const existingVars = new Set();
|
|
275
|
+
for (const line of envText.split(/\r?\n/)) {
|
|
276
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
|
|
277
|
+
if (m) existingVars.add(m[1]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const additions = [];
|
|
281
|
+
for (const m of moves) {
|
|
282
|
+
if (!existingVars.has(m.envVar)) {
|
|
283
|
+
additions.push(`${m.envVar}=${m.value}`);
|
|
284
|
+
existingVars.add(m.envVar);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (additions.length) {
|
|
289
|
+
try {
|
|
290
|
+
writeFileSync(envPath, envText + additions.join('\n') + '\n');
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error(`mnfst-render: failed to write ${envPath}: ${err.message}`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
console.warn('');
|
|
298
|
+
console.warn('mnfst-render: relocated credentials from manifest.json to .env');
|
|
299
|
+
for (const m of moves) {
|
|
300
|
+
console.warn(` • ${m.displayPath} → \${${m.envVar}}`);
|
|
301
|
+
}
|
|
302
|
+
if (additions.length) {
|
|
303
|
+
console.warn(` Appended ${additions.length} var(s) to .env (verify .env is in .gitignore).`);
|
|
304
|
+
} else {
|
|
305
|
+
console.warn(' .env already had matching vars; manifest.json placeholders now point at them.');
|
|
306
|
+
}
|
|
307
|
+
console.warn(' Browser-side dev needs window.env populated separately (e.g. env.local.js).');
|
|
308
|
+
console.warn('');
|
|
309
|
+
|
|
310
|
+
return manifest;
|
|
311
|
+
}
|
|
312
|
+
|
|
228
313
|
function normalizeLocaleRouteExclude(val) {
|
|
229
314
|
if (val == null) return [];
|
|
230
315
|
if (Array.isArray(val)) return val.map((s) => String(s).trim()).filter(Boolean);
|
|
@@ -236,44 +321,44 @@ function resolveConfig() {
|
|
|
236
321
|
const cli = parseArgs();
|
|
237
322
|
const cwd = process.cwd();
|
|
238
323
|
const root = resolve(cwd, cli.root ?? '.');
|
|
239
|
-
const manifest = loadConfig(root);
|
|
240
|
-
const
|
|
324
|
+
const manifest = relocateSecretsToEnv(root, loadConfig(root));
|
|
325
|
+
const ren = manifest.render ?? {};
|
|
241
326
|
|
|
242
|
-
const localUrl = (cli.localUrl ?? cli.baseUrl ?? process.env.PRERENDER_BASE ??
|
|
327
|
+
const localUrl = (cli.localUrl ?? cli.baseUrl ?? process.env.PRERENDER_BASE ?? ren.localUrl ?? ren.baseUrl)?.replace(/\/$/, '');
|
|
243
328
|
const serve = cli.localUrl ? false : (cli.serve !== undefined ? !!cli.serve : true);
|
|
244
329
|
if (!serve && !localUrl) {
|
|
245
|
-
console.error('prerender: localUrl is required when not using built-in server. Set manifest.
|
|
330
|
+
console.error('prerender: localUrl is required when not using built-in server. Set manifest.render.localUrl or use --local.');
|
|
246
331
|
process.exit(1);
|
|
247
332
|
}
|
|
248
|
-
const liveUrl = (cli.liveUrl ?? process.env.PRERENDER_LIVE ?? manifest.live_url ?? manifest.liveUrl ??
|
|
333
|
+
const liveUrl = (cli.liveUrl ?? process.env.PRERENDER_LIVE ?? manifest.live_url ?? manifest.liveUrl ?? ren.live_url ?? ren.liveUrl ?? localUrl ?? '')?.replace(/\/$/, '');
|
|
249
334
|
|
|
250
335
|
return {
|
|
251
336
|
localUrl: localUrl ?? '',
|
|
252
337
|
liveUrl,
|
|
253
338
|
serve,
|
|
254
|
-
output: resolve(root, cli.output ??
|
|
339
|
+
output: resolve(root, cli.output ?? ren.output ?? 'website'),
|
|
255
340
|
root,
|
|
256
|
-
routerBase:
|
|
341
|
+
routerBase: ren.routerBase ?? null,
|
|
257
342
|
/** Logical path prefixes (after locale) that skip sticky locale prefix; see manifest:locale-route-exclude */
|
|
258
343
|
localeRouteExclude: normalizeLocaleRouteExclude(
|
|
259
|
-
|
|
344
|
+
ren.localeRouteExclude ?? ren.localeStickyExclude
|
|
260
345
|
),
|
|
261
|
-
locales:
|
|
262
|
-
redirects: Array.isArray(
|
|
263
|
-
wait: cli.wait ??
|
|
346
|
+
locales: ren.locales,
|
|
347
|
+
redirects: Array.isArray(ren.redirects) ? ren.redirects : [],
|
|
348
|
+
wait: cli.wait ?? ren.wait ?? null,
|
|
264
349
|
waitAfterIdle: 0,
|
|
265
350
|
// Default concurrency: 2. Chromium per-page memory overhead is large and
|
|
266
351
|
// our hydration source-attribute map adds more per page. On big sites
|
|
267
352
|
// (>100 routes) higher concurrency crashes the browser with OOM/target
|
|
268
353
|
// closed errors. Users can override for small projects with --concurrency.
|
|
269
|
-
concurrency: Math.max(1, cli.concurrency ??
|
|
270
|
-
retries: Math.max(0, cli.retries ??
|
|
354
|
+
concurrency: Math.max(1, cli.concurrency ?? ren.concurrency ?? 2),
|
|
355
|
+
retries: Math.max(0, cli.retries ?? ren.retries ?? 2),
|
|
271
356
|
localeSubstitution: true,
|
|
272
357
|
localeSubstitutionExclude: [],
|
|
273
358
|
/** Explicit locale-neutral paths to render in addition to those discovered automatically.
|
|
274
359
|
* Each entry is expanded to all locale variants (e.g. "legal/privacy" → "cs/legal/privacy", ...) */
|
|
275
|
-
paths: Array.isArray(
|
|
276
|
-
?
|
|
360
|
+
paths: Array.isArray(ren.paths)
|
|
361
|
+
? ren.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
|
|
277
362
|
: [],
|
|
278
363
|
dryRun: !!cli.dryRun,
|
|
279
364
|
debugPrerender: !!cli.debugPrerender,
|
|
@@ -282,12 +367,12 @@ function resolveConfig() {
|
|
|
282
367
|
// fall back to the timeout. 10s gives slow data plugin pipelines a
|
|
283
368
|
// chance while bounding worst-case per-path overhead.
|
|
284
369
|
pipelineTimeout: 10000,
|
|
285
|
-
// SEO / AEO meta injection — see metaInjection() and the
|
|
370
|
+
// SEO / AEO meta injection — see metaInjection() and the render.meta
|
|
286
371
|
// section of manifest.json. Layered precedence (highest first):
|
|
287
372
|
// 1. <template data-head> per-route (already in DOM at snapshot time)
|
|
288
373
|
// 2. <head> in index.html (already in DOM at snapshot time)
|
|
289
|
-
// 3.
|
|
290
|
-
// 4.
|
|
374
|
+
// 3. render.meta.* expressions (Alpine-evaluated per route)
|
|
375
|
+
// 4. render.meta.fallback.* (static strings if expression empty)
|
|
291
376
|
// 5. PWA-style manifest.json fields (name, description, author, icons)
|
|
292
377
|
// 6. Smart defaults derived from the rendered DOM (h1, first p, etc.)
|
|
293
378
|
//
|
|
@@ -298,10 +383,10 @@ function resolveConfig() {
|
|
|
298
383
|
siteDescription: manifest.description || null,
|
|
299
384
|
siteAuthor: manifest.author || null,
|
|
300
385
|
icons: Array.isArray(manifest.icons) ? manifest.icons : [],
|
|
301
|
-
meta:
|
|
302
|
-
structuredData:
|
|
303
|
-
imageSnapshots:
|
|
304
|
-
defaults:
|
|
386
|
+
meta: ren.meta || null,
|
|
387
|
+
structuredData: ren.structuredData || null,
|
|
388
|
+
imageSnapshots: ren.meta?.imageSnapshots !== false, // default true
|
|
389
|
+
defaults: ren.meta?.defaults !== false, // default true
|
|
305
390
|
},
|
|
306
391
|
};
|
|
307
392
|
}
|
|
@@ -735,7 +820,7 @@ function stripDataTailwindAttr(html) {
|
|
|
735
820
|
* BEFORE the first paint — based on the user's `localStorage.theme` (their
|
|
736
821
|
* saved preference) or `prefers-color-scheme` (their system preference).
|
|
737
822
|
*
|
|
738
|
-
* The
|
|
823
|
+
* The color plugin (`manifest.color.js`) still runs later for reactivity
|
|
739
824
|
* (Alpine bindings, click handlers, system-preference change listener), but
|
|
740
825
|
* the initial paint already has the correct class so there's no flash.
|
|
741
826
|
*/
|
|
@@ -841,9 +926,9 @@ function promptContinueWithRuntimeTailwind(rootDir) {
|
|
|
841
926
|
/**
|
|
842
927
|
* Build a static Tailwind stylesheet via @tailwindcss/cli (v4+), scanning project sources.
|
|
843
928
|
* Only runs when the project uses data-tailwind on the manifest script tag (auto-detected).
|
|
844
|
-
* Set manifest.
|
|
929
|
+
* Set manifest.render.tailwindInput to a custom CSS entry file if needed.
|
|
845
930
|
*/
|
|
846
|
-
function runTailwindCliForPrerender(rootDir, outputDir,
|
|
931
|
+
function runTailwindCliForPrerender(rootDir, outputDir, ren) {
|
|
847
932
|
if (!indexHtmlUsesTailwind(rootDir)) return false;
|
|
848
933
|
|
|
849
934
|
const outCss = join(outputDir, 'prerender.tailwind.css');
|
|
@@ -859,7 +944,7 @@ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
|
|
|
859
944
|
}
|
|
860
945
|
let inputPath = null;
|
|
861
946
|
let createdTempInput = false;
|
|
862
|
-
const userInput =
|
|
947
|
+
const userInput = ren?.tailwindInput;
|
|
863
948
|
if (typeof userInput === 'string' && userInput.trim()) {
|
|
864
949
|
inputPath = resolve(rootDir, userInput.trim());
|
|
865
950
|
}
|
|
@@ -890,10 +975,15 @@ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
|
|
|
890
975
|
}
|
|
891
976
|
|
|
892
977
|
process.stdout.write('prerender: compiling Tailwind CSS (this may take a minute)...\n');
|
|
893
|
-
|
|
978
|
+
// On Windows, invoke the .cmd shim directly instead of routing through
|
|
979
|
+
// `shell: true`. With `shell: true`, every argument (including the user-
|
|
980
|
+
// controlled `tailwindInput` path from manifest.json) gets parsed by cmd.exe
|
|
981
|
+
// — so a value like `styles.css & evilcmd` would run `evilcmd`. Calling
|
|
982
|
+
// npx.cmd directly keeps args as a literal argv with no shell interpretation.
|
|
983
|
+
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
984
|
+
const r = spawnSync(command, args, {
|
|
894
985
|
cwd: rootDir,
|
|
895
986
|
encoding: 'utf8',
|
|
896
|
-
shell: process.platform === 'win32',
|
|
897
987
|
});
|
|
898
988
|
if (createdTempInput) {
|
|
899
989
|
try {
|
|
@@ -903,7 +993,7 @@ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
|
|
|
903
993
|
}
|
|
904
994
|
}
|
|
905
995
|
if (r.status !== 0) {
|
|
906
|
-
console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or check tailwindInput in manifest.
|
|
996
|
+
console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or check tailwindInput in manifest.render.');
|
|
907
997
|
if (r.stderr) console.error(r.stderr);
|
|
908
998
|
if (r.stdout) console.error(r.stdout);
|
|
909
999
|
return false;
|
|
@@ -1745,16 +1835,100 @@ function resolveHeadXBindings(html, xData) {
|
|
|
1745
1835
|
// .fallback.image), capture a 1200×630 PNG of the rendered page and use that as
|
|
1746
1836
|
// the og:image / twitter:image. Saved to <output>/og/<sanitized-path>.png.
|
|
1747
1837
|
//
|
|
1748
|
-
// 1200×630 is the OpenGraph / Twitter / LinkedIn recommended dimension.
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1838
|
+
// 1200×630 is the OpenGraph / Twitter / LinkedIn recommended dimension.
|
|
1839
|
+
|
|
1840
|
+
const sha = (s) => createHash('sha256').update(String(s)).digest('hex').slice(0, 16);
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Hash of the project-wide assets that affect every page's visual output
|
|
1844
|
+
* (theme CSS, manifest config, root HTML shell). Computed once per prerender
|
|
1845
|
+
* run and folded into each route's snapshot-cache key so that touching any of
|
|
1846
|
+
* these invalidates every cached OG image — a more correct behaviour than
|
|
1847
|
+
* per-route source-mtime caching, which would miss shared-chrome changes.
|
|
1848
|
+
*
|
|
1849
|
+
* Files included are conventional Manifest project assets that influence
|
|
1850
|
+
* layout/theme; missing files are recorded as the literal `missing` so the
|
|
1851
|
+
* hash still differs from an installation that has the file present.
|
|
1852
|
+
*/
|
|
1853
|
+
function computeGlobalAssetSignature(rootDir) {
|
|
1854
|
+
const candidates = [
|
|
1855
|
+
'manifest.json',
|
|
1856
|
+
'manifest.theme.css',
|
|
1857
|
+
'manifest.utilities.css',
|
|
1858
|
+
'index.html',
|
|
1859
|
+
];
|
|
1860
|
+
const parts = candidates.map((rel) => {
|
|
1861
|
+
const p = join(rootDir, rel);
|
|
1862
|
+
try {
|
|
1863
|
+
return `${rel}:${sha(readFileSync(p, 'utf8'))}`;
|
|
1864
|
+
} catch {
|
|
1865
|
+
return `${rel}:missing`;
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
return sha(parts.join('|'));
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Snapshot the page at 1200×630 and write to <output>/og/<slug>.png. Cache
|
|
1873
|
+
* sidecar lives in <root>/.mnfst-cache/og/ — outside the output dir, which is
|
|
1874
|
+
* wiped at the start of every prerender. On cache hit, the cached PNG is
|
|
1875
|
+
* copied into the output dir and the screenshot is skipped — saves ~0.2–0.5s
|
|
1876
|
+
* per hit, which adds up across hundreds of routes × locales. Hash inputs:
|
|
1877
|
+
* - globalAssetSignature (theme CSS / manifest config / root HTML)
|
|
1878
|
+
* - body outerHTML, normalised to strip non-visual volatile attributes
|
|
1879
|
+
* - html.className (theme variant: light/dark/etc.)
|
|
1880
|
+
*/
|
|
1881
|
+
async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, cacheDir) {
|
|
1752
1882
|
const fileSeg = pathSeg === '' || pathSeg === '__404__'
|
|
1753
1883
|
? 'index'
|
|
1754
1884
|
: pathSeg.replace(/\//g, '-').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
1755
1885
|
const ogDir = join(outputDir, 'og');
|
|
1756
1886
|
try { mkdirSync(ogDir, { recursive: true }); } catch { /* exists */ }
|
|
1757
1887
|
const filePath = join(ogDir, `${fileSeg}.png`);
|
|
1888
|
+
// Cache locations — outside the output dir so they survive the per-run
|
|
1889
|
+
// rmSync. cacheDir is .mnfst-cache/og under the project root.
|
|
1890
|
+
const cachePngPath = cacheDir ? join(cacheDir, `${fileSeg}.png`) : null;
|
|
1891
|
+
const cacheHashPath = cacheDir ? join(cacheDir, `${fileSeg}.hash`) : null;
|
|
1892
|
+
|
|
1893
|
+
// Cache lookup: fingerprint the rendered DOM and check against the stored
|
|
1894
|
+
// hash. The fingerprint normalises away attribute values assigned in
|
|
1895
|
+
// iteration order (data-hydrate-id, data-component-N) and randomly-generated
|
|
1896
|
+
// CSS anchor-name positioning IDs. Without normalisation the hash would
|
|
1897
|
+
// never match across runs and the cache would always miss.
|
|
1898
|
+
let contentHash = null;
|
|
1899
|
+
try {
|
|
1900
|
+
const fingerprint = await page.evaluate(() => {
|
|
1901
|
+
const body = document.body?.outerHTML || '';
|
|
1902
|
+
const htmlClass = document.documentElement?.className || '';
|
|
1903
|
+
const normalised = body
|
|
1904
|
+
.replace(/\sdata-hydrate-id="[^"]*"/g, '')
|
|
1905
|
+
.replace(/\sdata-component="[^"]*"/g, '')
|
|
1906
|
+
.replace(/\sdata-pre-rendered="[^"]*"/g, '')
|
|
1907
|
+
.replace(/\sid="(?:tab-|code-)[^"]*"/g, '')
|
|
1908
|
+
.replace(/\saria-controls="(?:code-)[^"]*"/g, '')
|
|
1909
|
+
.replace(/\saria-labelledby="(?:tab-)[^"]*"/g, '')
|
|
1910
|
+
// CSS anchor-positioning IDs (e.g. `--dropdown-zc7nofh3c`) are
|
|
1911
|
+
// regenerated per run by the dropdown/popover system.
|
|
1912
|
+
.replace(/--dropdown-[a-z0-9]+/g, '--dropdown-X')
|
|
1913
|
+
.replace(/--popover-[a-z0-9]+/g, '--popover-X')
|
|
1914
|
+
.replace(/--anchor-[a-z0-9]+/g, '--anchor-X');
|
|
1915
|
+
return normalised + '\n@html:' + htmlClass;
|
|
1916
|
+
});
|
|
1917
|
+
contentHash = sha(`${globalAssetSignature || ''}|${fingerprint}`);
|
|
1918
|
+
if (cachePngPath && existsSync(cachePngPath) && existsSync(cacheHashPath)) {
|
|
1919
|
+
const stored = readFileSync(cacheHashPath, 'utf8').trim();
|
|
1920
|
+
if (stored === contentHash) {
|
|
1921
|
+
// Cache hit — copy the cached PNG into the output dir. We still need
|
|
1922
|
+
// a copy in /og/ so the served site has it; the cache just lets us
|
|
1923
|
+
// skip the screenshot + PNG-encode work.
|
|
1924
|
+
try {
|
|
1925
|
+
cpSync(cachePngPath, filePath);
|
|
1926
|
+
return `/og/${fileSeg}.png`;
|
|
1927
|
+
} catch { /* copy failure — fall through to fresh snapshot */ }
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
} catch { /* hash failure is non-fatal — fall through to fresh snapshot */ }
|
|
1931
|
+
|
|
1758
1932
|
try {
|
|
1759
1933
|
// Viewport stays at the page-creation default (1200×800). Clipping a
|
|
1760
1934
|
// 1200×630 region from the top gives the OG/Twitter card aspect ratio
|
|
@@ -1780,9 +1954,21 @@ async function takeOgSnapshot(page, outputDir, pathSeg) {
|
|
|
1780
1954
|
const sz = statSync(filePath).size;
|
|
1781
1955
|
if (sz < 15 * 1024) {
|
|
1782
1956
|
unlinkSync(filePath);
|
|
1957
|
+
// Drop the cache too so the next run doesn't trust it.
|
|
1958
|
+
if (cachePngPath) { try { unlinkSync(cachePngPath); } catch { /* missing is fine */ } }
|
|
1959
|
+
if (cacheHashPath) { try { unlinkSync(cacheHashPath); } catch { /* missing is fine */ } }
|
|
1783
1960
|
return null;
|
|
1784
1961
|
}
|
|
1785
1962
|
} catch { /* stat failure is non-fatal */ }
|
|
1963
|
+
// Populate the cache: copy the fresh PNG into the cache dir and write the
|
|
1964
|
+
// content hash sidecar. Hash failure earlier leaves contentHash null —
|
|
1965
|
+
// in that case we don't cache (correct fallback: prefer to re-snapshot
|
|
1966
|
+
// than to claim a stale cache is valid).
|
|
1967
|
+
if (cacheDir && contentHash) {
|
|
1968
|
+
try { mkdirSync(cacheDir, { recursive: true }); } catch { /* exists */ }
|
|
1969
|
+
try { cpSync(filePath, cachePngPath); } catch { /* ignore */ }
|
|
1970
|
+
try { writeFileSync(cacheHashPath, contentHash, 'utf8'); } catch { /* ignore */ }
|
|
1971
|
+
}
|
|
1786
1972
|
return `/og/${fileSeg}.png`;
|
|
1787
1973
|
} catch (e) {
|
|
1788
1974
|
// Failures here are non-fatal — fall back to whatever other og:image source
|
|
@@ -2661,7 +2847,7 @@ async function runPrerender(config) {
|
|
|
2661
2847
|
|
|
2662
2848
|
const defaultLocale = locales[0] ?? null;
|
|
2663
2849
|
const routeSegments = discoverRoutes(manifest, config.root);
|
|
2664
|
-
// Merge any explicitly configured paths (manifest.
|
|
2850
|
+
// Merge any explicitly configured paths (manifest.render.paths) into the discovered segments.
|
|
2665
2851
|
// These are treated as locale-neutral and get full locale-expansion like all other discovered paths.
|
|
2666
2852
|
if (config.paths && config.paths.length > 0) {
|
|
2667
2853
|
const segSet = new Set(routeSegments);
|
|
@@ -2708,7 +2894,7 @@ async function runPrerender(config) {
|
|
|
2708
2894
|
const outputResolved = resolve(config.output);
|
|
2709
2895
|
const rootResolved = resolve(config.root);
|
|
2710
2896
|
// Router base = URL pathname to the app root. When dist is deployed as site root (e.g. Appwrite), use "".
|
|
2711
|
-
// Set manifest.
|
|
2897
|
+
// Set manifest.render.routerBase only when the app is served from a subpath (e.g. /app).
|
|
2712
2898
|
let routerBasePath = null;
|
|
2713
2899
|
if (config.routerBase != null && String(config.routerBase).trim() !== '') {
|
|
2714
2900
|
const trimmed = String(config.routerBase).replace(/^\/+|\/+$/g, '').trim();
|
|
@@ -2723,9 +2909,9 @@ async function runPrerender(config) {
|
|
|
2723
2909
|
mkdirSync(outputResolved, { recursive: true });
|
|
2724
2910
|
copyProjectIntoDist(rootResolved, outputResolved);
|
|
2725
2911
|
|
|
2726
|
-
const
|
|
2727
|
-
const bundleUtilities =
|
|
2728
|
-
const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved,
|
|
2912
|
+
const ren = manifest.render ?? {};
|
|
2913
|
+
const bundleUtilities = ren.utilitiesBundle !== false;
|
|
2914
|
+
const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved, ren);
|
|
2729
2915
|
const utilityBlocks = [];
|
|
2730
2916
|
|
|
2731
2917
|
// Launch a fresh browser instance. Chromium is known to accumulate memory
|
|
@@ -2757,12 +2943,18 @@ async function runPrerender(config) {
|
|
|
2757
2943
|
console.error(' npm i -D puppeteer-core @sparticuz/chromium');
|
|
2758
2944
|
process.exit(1);
|
|
2759
2945
|
}
|
|
2946
|
+
// Chrome's sandbox is the primary defense against renderer-process
|
|
2947
|
+
// exploits — disabling it means any RCE in a rendered page runs as the
|
|
2948
|
+
// developer's UID with full filesystem access. We render arbitrary CDN
|
|
2949
|
+
// scripts and third-party iframes, so the threat is real. Opt-in only
|
|
2950
|
+
// for CI environments where the sandbox legitimately can't initialize:
|
|
2951
|
+
// set MNFST_RENDER_NO_SANDBOX=1 to add the flags back.
|
|
2952
|
+
const extraArgs = process.env.MNFST_RENDER_NO_SANDBOX === '1'
|
|
2953
|
+
? ['--no-sandbox', '--disable-setuid-sandbox']
|
|
2954
|
+
: [];
|
|
2760
2955
|
return await puppeteer.default.launch({
|
|
2761
2956
|
headless: true,
|
|
2762
|
-
args:
|
|
2763
|
-
'--no-sandbox',
|
|
2764
|
-
'--disable-setuid-sandbox',
|
|
2765
|
-
],
|
|
2957
|
+
args: extraArgs,
|
|
2766
2958
|
});
|
|
2767
2959
|
}
|
|
2768
2960
|
}
|
|
@@ -2773,12 +2965,12 @@ async function runPrerender(config) {
|
|
|
2773
2965
|
// substantial, and we also now maintain a per-page source-attribute Map for
|
|
2774
2966
|
// the hydration contract. On large sites (>100 routes) higher concurrency
|
|
2775
2967
|
// spikes memory and crashes the browser. Users can still override via
|
|
2776
|
-
// --concurrency or manifest.
|
|
2968
|
+
// --concurrency or manifest.render.concurrency.
|
|
2777
2969
|
const concurrency = config.concurrency;
|
|
2778
2970
|
const maxRetries = config.retries ?? 2;
|
|
2779
2971
|
// Recycle the browser every N processed pages to bound resource growth.
|
|
2780
|
-
// Configurable via manifest.
|
|
2781
|
-
const browserRecycleEvery = Math.max(0,
|
|
2972
|
+
// Configurable via manifest.render.browserRecycleEvery.
|
|
2973
|
+
const browserRecycleEvery = Math.max(0, ren.browserRecycleEvery ?? 40);
|
|
2782
2974
|
let pagesSinceRecycle = 0;
|
|
2783
2975
|
const recycleLock = { busy: false };
|
|
2784
2976
|
// Workers block on this promise before touching `browser`. While a recycle
|
|
@@ -2856,6 +3048,19 @@ async function runPrerender(config) {
|
|
|
2856
3048
|
|
|
2857
3049
|
process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution)...\n`);
|
|
2858
3050
|
|
|
3051
|
+
// Asset-wide fingerprint used as a cache-invalidator for OG snapshots:
|
|
3052
|
+
// changes to theme CSS, manifest config, or the root index.html mean every
|
|
3053
|
+
// route's visual chrome has changed, so the snapshot cache must drop. Per-
|
|
3054
|
+
// route content hashes (in takeOgSnapshot) catch route-specific changes.
|
|
3055
|
+
// The cache lives at <root>/.mnfst-cache/og/ — survives the output-dir
|
|
3056
|
+
// rmSync that fires at the start of every prerender.
|
|
3057
|
+
const globalAssetSig = config.seo?.imageSnapshots
|
|
3058
|
+
? computeGlobalAssetSignature(config.root)
|
|
3059
|
+
: '';
|
|
3060
|
+
const ogCacheDir = config.seo?.imageSnapshots
|
|
3061
|
+
? join(config.root, '.mnfst-cache', 'og')
|
|
3062
|
+
: null;
|
|
3063
|
+
|
|
2859
3064
|
function pushDebug(row) {
|
|
2860
3065
|
if (!config.debugPrerender) return;
|
|
2861
3066
|
debugRows.push(row);
|
|
@@ -3111,7 +3316,7 @@ async function runPrerender(config) {
|
|
|
3111
3316
|
|| !!config.seo.meta?.fallback?.image
|
|
3112
3317
|
|| await page.evaluate(() => !!document.head.querySelector('meta[property="og:image"]'));
|
|
3113
3318
|
if (!ogImageHandled) {
|
|
3114
|
-
earlySnapshotUrl = await takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg);
|
|
3319
|
+
earlySnapshotUrl = await takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir);
|
|
3115
3320
|
}
|
|
3116
3321
|
}
|
|
3117
3322
|
|
|
@@ -3328,14 +3533,14 @@ async function runPrerender(config) {
|
|
|
3328
3533
|
// Interactive Manifest-registered directives that attach click/hover/
|
|
3329
3534
|
// observer state at runtime and therefore need the live Alpine scope.
|
|
3330
3535
|
const INTERACTIVE_DIRECTIVES = new Set([
|
|
3331
|
-
'x-
|
|
3536
|
+
'x-color', 'x-dropdown', 'x-tooltip', 'x-tab', 'x-tabpanel',
|
|
3332
3537
|
'x-toast', 'x-carousel', 'x-resize', 'x-anchors', 'x-model',
|
|
3333
3538
|
'x-files', 'x-data-files',
|
|
3334
3539
|
]);
|
|
3335
3540
|
// Runtime-only Alpine magics whose values change after the prerender
|
|
3336
3541
|
// snapshot (e.g. via media query, route change, auth state). Bindings
|
|
3337
3542
|
// referencing these must re-evaluate in the live page.
|
|
3338
|
-
const RUNTIME_MAGIC_RX = /(?<!['"])\$(
|
|
3543
|
+
const RUNTIME_MAGIC_RX = /(?<!['"])\$(color|locale|url|auth|search|query|toast)\b/;
|
|
3339
3544
|
|
|
3340
3545
|
const isDiffBindingAttr = (name) =>
|
|
3341
3546
|
name === ':class' || name === 'x-bind:class' ||
|