mnfst-render 0.5.31 → 0.5.33

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.
Files changed (2) hide show
  1. package/manifest.render.mjs +67 -17
  2. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  /* Manifest Render */
4
4
 
5
- import { readFileSync, readSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync } from 'node:fs';
5
+ import { readFileSync, readSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync, renameSync } from 'node:fs';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { join, resolve, dirname, relative, basename, sep } from 'node:path';
8
8
  import { createServer } from 'node:http';
@@ -1811,9 +1811,15 @@ function computeGlobalAssetSignature(rootDir) {
1811
1811
  * - html.className (theme variant: light/dark/etc.)
1812
1812
  */
1813
1813
  async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, cacheDir) {
1814
- const fileSeg = pathSeg === '' || pathSeg === '__404__'
1814
+ // The 404 page must NOT share the homepage's 'index' slug: both pages render
1815
+ // concurrently and would otherwise race on the same og/index.png + cache
1816
+ // files. The 404 (route hidden, near-empty body) screenshots blank, and that
1817
+ // blank would clobber — or get mis-read as — the homepage's real snapshot.
1818
+ const fileSeg = pathSeg === ''
1815
1819
  ? 'index'
1816
- : pathSeg.replace(/\//g, '-').replace(/[^a-zA-Z0-9_-]/g, '_');
1820
+ : pathSeg === '__404__'
1821
+ ? '404'
1822
+ : pathSeg.replace(/\//g, '-').replace(/[^a-zA-Z0-9_-]/g, '_');
1817
1823
  const ogDir = join(outputDir, 'og');
1818
1824
  try { mkdirSync(ogDir, { recursive: true }); } catch { /* exists */ }
1819
1825
  const filePath = join(ogDir, `${fileSeg}.png`);
@@ -1822,6 +1828,16 @@ async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, ca
1822
1828
  const cachePngPath = cacheDir ? join(cacheDir, `${fileSeg}.png`) : null;
1823
1829
  const cacheHashPath = cacheDir ? join(cacheDir, `${fileSeg}.hash`) : null;
1824
1830
 
1831
+ // Restore the last good cached PNG into the output dir. Used whenever a fresh
1832
+ // snapshot can't be produced (error or blank result) so a transient failure
1833
+ // never leaves the page with no OG image — and never destroys the prior.
1834
+ const restoreFromCache = () => {
1835
+ if (cachePngPath && existsSync(cachePngPath)) {
1836
+ try { cpSync(cachePngPath, filePath); return `/og/${fileSeg}.png`; } catch { /* ignore */ }
1837
+ }
1838
+ return null;
1839
+ };
1840
+
1825
1841
  // Cache lookup: fingerprint the rendered DOM and check against the stored
1826
1842
  // hash. The fingerprint normalises away attribute values assigned in
1827
1843
  // iteration order (data-hydrate-id, data-component-N) and randomly-generated
@@ -1885,11 +1901,12 @@ async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, ca
1885
1901
  try {
1886
1902
  const sz = statSync(filePath).size;
1887
1903
  if (sz < 15 * 1024) {
1888
- unlinkSync(filePath);
1889
- // Drop the cache too so the next run doesn't trust it.
1890
- if (cachePngPath) { try { unlinkSync(cachePngPath); } catch { /* missing is fine */ } }
1891
- if (cacheHashPath) { try { unlinkSync(cacheHashPath); } catch { /* missing is fine */ } }
1892
- return null;
1904
+ // Blank/header-only snapshot — don't trust it, but don't destroy the
1905
+ // good cached prior either (a transient blank must not wipe a real
1906
+ // image). Drop the blank output and restore the last good cached PNG;
1907
+ // if there's no cache, fall through to other og:image sources.
1908
+ try { unlinkSync(filePath); } catch { /* missing is fine */ }
1909
+ return restoreFromCache();
1893
1910
  }
1894
1911
  } catch { /* stat failure is non-fatal */ }
1895
1912
  // Populate the cache: copy the fresh PNG into the cache dir and write the
@@ -1903,10 +1920,12 @@ async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, ca
1903
1920
  }
1904
1921
  return `/og/${fileSeg}.png`;
1905
1922
  } catch (e) {
1906
- // Failures here are non-fatal fall back to whatever other og:image source
1907
- // is available (manifest icon, first content <img>, etc.).
1923
+ // Failures here are non-fatal and must be non-destructive: prefer the last
1924
+ // good cached PNG (restored into the output) over leaving the page with no
1925
+ // OG image. Only if there's no cache do we fall through to other og:image
1926
+ // sources (manifest icon, first content <img>, etc.).
1908
1927
  console.error(`prerender: og snapshot failed for /${pathSeg || ''}: ${e?.message || e}`);
1909
- return null;
1928
+ return restoreFromCache();
1910
1929
  }
1911
1930
  }
1912
1931
 
@@ -2101,6 +2120,10 @@ async function injectMetaInDom(page, ctx) {
2101
2120
  } else if (typeof expr === 'boolean' || typeof expr === 'number') {
2102
2121
  return String(expr);
2103
2122
  }
2123
+ // Image only: the per-page auto-snapshot outranks the generic static
2124
+ // fallback.image — it depicts this exact page and is sized for OG cards.
2125
+ // The fallback then only applies to pages with no snapshot.
2126
+ if (key === 'image' && ctx.snapshotUrl) return ctx.snapshotUrl;
2104
2127
  // Layer 4: explicit fallback
2105
2128
  const fallback = exprMap.fallback?.[key];
2106
2129
  if (fallback) return String(fallback);
@@ -2989,7 +3012,7 @@ async function runPrerender(config) {
2989
3012
  return;
2990
3013
  }
2991
3014
 
2992
- const outputResolved = resolve(config.output);
3015
+ const finalOutput = resolve(config.output);
2993
3016
  const rootResolved = resolve(config.root);
2994
3017
  // Router base = URL pathname to the app root. When dist is deployed as site root (e.g. Appwrite), use "".
2995
3018
  // Set manifest.prerender.routerBase only when the app is served from a subpath (e.g. /app).
@@ -3001,11 +3024,27 @@ async function runPrerender(config) {
3001
3024
  routerBasePath = '';
3002
3025
  }
3003
3026
 
3004
- if (existsSync(outputResolved)) {
3005
- rmSync(outputResolved, { recursive: true });
3027
+ // Atomic output: build into a sibling staging dir, then swap it into place
3028
+ // only after every step below succeeds (see the end of this function). A
3029
+ // crashed or interrupted render therefore never leaves a half-written or empty
3030
+ // output dir for mnfst-publish to ship — the previous good build stays put
3031
+ // until the new one is complete.
3032
+ const stagingOutput = finalOutput + '.mnfst-staging';
3033
+ if (existsSync(stagingOutput)) rmSync(stagingOutput, { recursive: true });
3034
+ mkdirSync(stagingOutput, { recursive: true });
3035
+ // Redirect ALL downstream writes (both config.output and outputResolved) to
3036
+ // the staging dir for the remainder of the build.
3037
+ config.output = stagingOutput;
3038
+ const outputResolved = stagingOutput;
3039
+ // Don't copy the previous build (the final output dir) into staging — only the
3040
+ // staging dir's own basename is excluded by copyProjectIntoDist.
3041
+ const finalBasename = basename(finalOutput);
3042
+ COPY_EXCLUDE.add(finalBasename);
3043
+ try {
3044
+ copyProjectIntoDist(rootResolved, outputResolved);
3045
+ } finally {
3046
+ COPY_EXCLUDE.delete(finalBasename);
3006
3047
  }
3007
- mkdirSync(outputResolved, { recursive: true });
3008
- copyProjectIntoDist(rootResolved, outputResolved);
3009
3048
  // Env placeholders (${VAR}) only resolve at runtime via window.env (populated
3010
3049
  // by the mnfst-run dev server from .env). A static prod build has no
3011
3050
  // window.env, so any ${VAR} left in the shipped manifest.json stays literal
@@ -3418,8 +3457,13 @@ async function runPrerender(config) {
3418
3457
  // by data-head, prerender.meta config, or an explicit fallback.
3419
3458
  let earlySnapshotUrl = null;
3420
3459
  if (config.seo.imageSnapshots) {
3460
+ // Only an EXPLICIT per-page image suppresses the auto-snapshot:
3461
+ // prerender.meta.image (a config expression) or an og:image already in
3462
+ // the head (data-head / index.html). A generic prerender.meta.fallback
3463
+ // .image must NOT skip it — the per-page snapshot is the primary image
3464
+ // and the static fallback is only used when a snapshot isn't available
3465
+ // (see resolve('image') in injectMetaInDom).
3421
3466
  const ogImageHandled = !!config.seo.meta?.image
3422
- || !!config.seo.meta?.fallback?.image
3423
3467
  || await page.evaluate(() => !!document.head.querySelector('meta[property="og:image"]'));
3424
3468
  if (!ogImageHandled) {
3425
3469
  earlySnapshotUrl = await takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir);
@@ -4593,6 +4637,12 @@ async function runPrerender(config) {
4593
4637
  writeFileSync(join(config.output, '_redirects'), lines.join('\n'), 'utf8');
4594
4638
  }
4595
4639
 
4640
+ // Atomic swap: replace the previous output with the freshly built staging dir.
4641
+ // rename() is atomic on the same filesystem (staging is a sibling of output),
4642
+ // so a consumer never sees a partially written output directory.
4643
+ if (existsSync(finalOutput)) rmSync(finalOutput, { recursive: true });
4644
+ renameSync(stagingOutput, finalOutput);
4645
+ config.output = finalOutput;
4596
4646
  }
4597
4647
 
4598
4648
  // Auto-run main() when this file is the direct entry point (node manifest.render.mjs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.31",
3
+ "version": "0.5.33",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {