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 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.prerender.output` (default `website`).
11
+ The command reads `manifest.json` and writes rendered pages to `manifest.render.output` (default `website`).
@@ -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 { prerender: {} };
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 { prerender: {} };
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 pre = manifest.prerender ?? {};
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 ?? pre.localUrl ?? pre.baseUrl)?.replace(/\/$/, '');
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.prerender.localUrl or use --local.');
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 ?? pre.live_url ?? pre.liveUrl ?? localUrl ?? '')?.replace(/\/$/, '');
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 ?? pre.output ?? 'website'),
339
+ output: resolve(root, cli.output ?? ren.output ?? 'website'),
255
340
  root,
256
- routerBase: pre.routerBase ?? null,
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
- pre.localeRouteExclude ?? pre.localeStickyExclude
344
+ ren.localeRouteExclude ?? ren.localeStickyExclude
260
345
  ),
261
- locales: pre.locales,
262
- redirects: Array.isArray(pre.redirects) ? pre.redirects : [],
263
- wait: cli.wait ?? pre.wait ?? null,
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 ?? pre.concurrency ?? 2),
270
- retries: Math.max(0, cli.retries ?? pre.retries ?? 2),
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(pre.paths)
276
- ? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
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 prerender.meta
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. prerender.meta.* expressions (Alpine-evaluated per route)
290
- // 4. prerender.meta.fallback.* (static strings if expression empty)
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: pre.meta || null,
302
- structuredData: pre.structuredData || null,
303
- imageSnapshots: pre.meta?.imageSnapshots !== false, // default true
304
- defaults: pre.meta?.defaults !== false, // default true
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 colors plugin (`manifest.colors.js`) still runs later for reactivity
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.prerender.tailwindInput to a custom CSS entry file if needed.
929
+ * Set manifest.render.tailwindInput to a custom CSS entry file if needed.
845
930
  */
846
- function runTailwindCliForPrerender(rootDir, outputDir, pre) {
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 = pre?.tailwindInput;
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
- const r = spawnSync('npx', args, {
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.prerender.');
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. We set
1749
- // the viewport before snapshotting so layouts intended for desktop render
1750
- // correctly (mobile-first sites otherwise look misaligned in social previews).
1751
- async function takeOgSnapshot(page, outputDir, pathSeg) {
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.prerender.paths) into the discovered segments.
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.prerender.routerBase only when the app is served from a subpath (e.g. /app).
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 pre = manifest.prerender ?? {};
2727
- const bundleUtilities = pre.utilitiesBundle !== false;
2728
- const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved, pre);
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.prerender.concurrency.
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.prerender.browserRecycleEvery.
2781
- const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
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-colors', 'x-dropdown', 'x-tooltip', 'x-tab', 'x-tabpanel',
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 = /(?<!['"])\$(colors|locale|url|auth|search|query|toast)\b/;
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' ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.24",
3
+ "version": "0.5.25",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {