mnfst-render 0.5.24 → 0.5.26

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);
@@ -735,7 +736,7 @@ function stripDataTailwindAttr(html) {
735
736
  * BEFORE the first paint — based on the user's `localStorage.theme` (their
736
737
  * saved preference) or `prefers-color-scheme` (their system preference).
737
738
  *
738
- * The colors plugin (`manifest.colors.js`) still runs later for reactivity
739
+ * The color plugin (`manifest.color.js`) still runs later for reactivity
739
740
  * (Alpine bindings, click handlers, system-preference change listener), but
740
741
  * the initial paint already has the correct class so there's no flash.
741
742
  */
@@ -768,18 +769,18 @@ function debakeThemeClass(html) {
768
769
  return out;
769
770
  }
770
771
 
771
- /** Manifest utilities plugin: <style id="utility-styles"> and <style id="utility-styles-critical"> */
772
+ /** Manifest utilities plugin: <style id="manifest-styles"> and <style id="manifest-styles-critical"> */
772
773
  function extractUtilityStyleBlocks(html) {
773
774
  const blocks = [];
774
775
  let out = html.replace(
775
- /<style[^>]*\bid=["']utility-styles-critical["'][^>]*>([\s\S]*?)<\/style>/gi,
776
+ /<style[^>]*\bid=["']manifest-styles-critical["'][^>]*>([\s\S]*?)<\/style>/gi,
776
777
  (_, css) => {
777
778
  const t = (css || '').trim();
778
779
  if (t) blocks.push({ kind: 'critical', css: t });
779
780
  return '';
780
781
  }
781
782
  );
782
- out = out.replace(/<style[^>]*\bid=["']utility-styles["'][^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
783
+ out = out.replace(/<style[^>]*\bid=["']manifest-styles["'][^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
783
784
  const t = (css || '').trim();
784
785
  if (t) blocks.push({ kind: 'main', css: t });
785
786
  return '';
@@ -1745,16 +1746,100 @@ function resolveHeadXBindings(html, xData) {
1745
1746
  // .fallback.image), capture a 1200×630 PNG of the rendered page and use that as
1746
1747
  // the og:image / twitter:image. Saved to <output>/og/<sanitized-path>.png.
1747
1748
  //
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) {
1749
+ // 1200×630 is the OpenGraph / Twitter / LinkedIn recommended dimension.
1750
+
1751
+ const sha = (s) => createHash('sha256').update(String(s)).digest('hex').slice(0, 16);
1752
+
1753
+ /**
1754
+ * Hash of the project-wide assets that affect every page's visual output
1755
+ * (theme CSS, manifest config, root HTML shell). Computed once per prerender
1756
+ * run and folded into each route's snapshot-cache key so that touching any of
1757
+ * these invalidates every cached OG image — a more correct behaviour than
1758
+ * per-route source-mtime caching, which would miss shared-chrome changes.
1759
+ *
1760
+ * Files included are conventional Manifest project assets that influence
1761
+ * layout/theme; missing files are recorded as the literal `missing` so the
1762
+ * hash still differs from an installation that has the file present.
1763
+ */
1764
+ function computeGlobalAssetSignature(rootDir) {
1765
+ const candidates = [
1766
+ 'manifest.json',
1767
+ 'manifest.theme.css',
1768
+ 'manifest.utilities.css',
1769
+ 'index.html',
1770
+ ];
1771
+ const parts = candidates.map((rel) => {
1772
+ const p = join(rootDir, rel);
1773
+ try {
1774
+ return `${rel}:${sha(readFileSync(p, 'utf8'))}`;
1775
+ } catch {
1776
+ return `${rel}:missing`;
1777
+ }
1778
+ });
1779
+ return sha(parts.join('|'));
1780
+ }
1781
+
1782
+ /**
1783
+ * Snapshot the page at 1200×630 and write to <output>/og/<slug>.png. Cache
1784
+ * sidecar lives in <root>/.mnfst-cache/og/ — outside the output dir, which is
1785
+ * wiped at the start of every prerender. On cache hit, the cached PNG is
1786
+ * copied into the output dir and the screenshot is skipped — saves ~0.2–0.5s
1787
+ * per hit, which adds up across hundreds of routes × locales. Hash inputs:
1788
+ * - globalAssetSignature (theme CSS / manifest config / root HTML)
1789
+ * - body outerHTML, normalised to strip non-visual volatile attributes
1790
+ * - html.className (theme variant: light/dark/etc.)
1791
+ */
1792
+ async function takeOgSnapshot(page, outputDir, pathSeg, globalAssetSignature, cacheDir) {
1752
1793
  const fileSeg = pathSeg === '' || pathSeg === '__404__'
1753
1794
  ? 'index'
1754
1795
  : pathSeg.replace(/\//g, '-').replace(/[^a-zA-Z0-9_-]/g, '_');
1755
1796
  const ogDir = join(outputDir, 'og');
1756
1797
  try { mkdirSync(ogDir, { recursive: true }); } catch { /* exists */ }
1757
1798
  const filePath = join(ogDir, `${fileSeg}.png`);
1799
+ // Cache locations — outside the output dir so they survive the per-run
1800
+ // rmSync. cacheDir is .mnfst-cache/og under the project root.
1801
+ const cachePngPath = cacheDir ? join(cacheDir, `${fileSeg}.png`) : null;
1802
+ const cacheHashPath = cacheDir ? join(cacheDir, `${fileSeg}.hash`) : null;
1803
+
1804
+ // Cache lookup: fingerprint the rendered DOM and check against the stored
1805
+ // hash. The fingerprint normalises away attribute values assigned in
1806
+ // iteration order (data-hydrate-id, data-component-N) and randomly-generated
1807
+ // CSS anchor-name positioning IDs. Without normalisation the hash would
1808
+ // never match across runs and the cache would always miss.
1809
+ let contentHash = null;
1810
+ try {
1811
+ const fingerprint = await page.evaluate(() => {
1812
+ const body = document.body?.outerHTML || '';
1813
+ const htmlClass = document.documentElement?.className || '';
1814
+ const normalised = body
1815
+ .replace(/\sdata-hydrate-id="[^"]*"/g, '')
1816
+ .replace(/\sdata-component="[^"]*"/g, '')
1817
+ .replace(/\sdata-pre-rendered="[^"]*"/g, '')
1818
+ .replace(/\sid="(?:tab-|code-)[^"]*"/g, '')
1819
+ .replace(/\saria-controls="(?:code-)[^"]*"/g, '')
1820
+ .replace(/\saria-labelledby="(?:tab-)[^"]*"/g, '')
1821
+ // CSS anchor-positioning IDs (e.g. `--dropdown-zc7nofh3c`) are
1822
+ // regenerated per run by the dropdown/popover system.
1823
+ .replace(/--dropdown-[a-z0-9]+/g, '--dropdown-X')
1824
+ .replace(/--popover-[a-z0-9]+/g, '--popover-X')
1825
+ .replace(/--anchor-[a-z0-9]+/g, '--anchor-X');
1826
+ return normalised + '\n@html:' + htmlClass;
1827
+ });
1828
+ contentHash = sha(`${globalAssetSignature || ''}|${fingerprint}`);
1829
+ if (cachePngPath && existsSync(cachePngPath) && existsSync(cacheHashPath)) {
1830
+ const stored = readFileSync(cacheHashPath, 'utf8').trim();
1831
+ if (stored === contentHash) {
1832
+ // Cache hit — copy the cached PNG into the output dir. We still need
1833
+ // a copy in /og/ so the served site has it; the cache just lets us
1834
+ // skip the screenshot + PNG-encode work.
1835
+ try {
1836
+ cpSync(cachePngPath, filePath);
1837
+ return `/og/${fileSeg}.png`;
1838
+ } catch { /* copy failure — fall through to fresh snapshot */ }
1839
+ }
1840
+ }
1841
+ } catch { /* hash failure is non-fatal — fall through to fresh snapshot */ }
1842
+
1758
1843
  try {
1759
1844
  // Viewport stays at the page-creation default (1200×800). Clipping a
1760
1845
  // 1200×630 region from the top gives the OG/Twitter card aspect ratio
@@ -1780,9 +1865,21 @@ async function takeOgSnapshot(page, outputDir, pathSeg) {
1780
1865
  const sz = statSync(filePath).size;
1781
1866
  if (sz < 15 * 1024) {
1782
1867
  unlinkSync(filePath);
1868
+ // Drop the cache too so the next run doesn't trust it.
1869
+ if (cachePngPath) { try { unlinkSync(cachePngPath); } catch { /* missing is fine */ } }
1870
+ if (cacheHashPath) { try { unlinkSync(cacheHashPath); } catch { /* missing is fine */ } }
1783
1871
  return null;
1784
1872
  }
1785
1873
  } catch { /* stat failure is non-fatal */ }
1874
+ // Populate the cache: copy the fresh PNG into the cache dir and write the
1875
+ // content hash sidecar. Hash failure earlier leaves contentHash null —
1876
+ // in that case we don't cache (correct fallback: prefer to re-snapshot
1877
+ // than to claim a stale cache is valid).
1878
+ if (cacheDir && contentHash) {
1879
+ try { mkdirSync(cacheDir, { recursive: true }); } catch { /* exists */ }
1880
+ try { cpSync(filePath, cachePngPath); } catch { /* ignore */ }
1881
+ try { writeFileSync(cacheHashPath, contentHash, 'utf8'); } catch { /* ignore */ }
1882
+ }
1786
1883
  return `/og/${fileSeg}.png`;
1787
1884
  } catch (e) {
1788
1885
  // Failures here are non-fatal — fall back to whatever other og:image source
@@ -2856,6 +2953,19 @@ async function runPrerender(config) {
2856
2953
 
2857
2954
  process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution)...\n`);
2858
2955
 
2956
+ // Asset-wide fingerprint used as a cache-invalidator for OG snapshots:
2957
+ // changes to theme CSS, manifest config, or the root index.html mean every
2958
+ // route's visual chrome has changed, so the snapshot cache must drop. Per-
2959
+ // route content hashes (in takeOgSnapshot) catch route-specific changes.
2960
+ // The cache lives at <root>/.mnfst-cache/og/ — survives the output-dir
2961
+ // rmSync that fires at the start of every prerender.
2962
+ const globalAssetSig = config.seo?.imageSnapshots
2963
+ ? computeGlobalAssetSignature(config.root)
2964
+ : '';
2965
+ const ogCacheDir = config.seo?.imageSnapshots
2966
+ ? join(config.root, '.mnfst-cache', 'og')
2967
+ : null;
2968
+
2859
2969
  function pushDebug(row) {
2860
2970
  if (!config.debugPrerender) return;
2861
2971
  debugRows.push(row);
@@ -3111,7 +3221,7 @@ async function runPrerender(config) {
3111
3221
  || !!config.seo.meta?.fallback?.image
3112
3222
  || await page.evaluate(() => !!document.head.querySelector('meta[property="og:image"]'));
3113
3223
  if (!ogImageHandled) {
3114
- earlySnapshotUrl = await takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg);
3224
+ earlySnapshotUrl = await takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir);
3115
3225
  }
3116
3226
  }
3117
3227
 
@@ -3328,14 +3438,14 @@ async function runPrerender(config) {
3328
3438
  // Interactive Manifest-registered directives that attach click/hover/
3329
3439
  // observer state at runtime and therefore need the live Alpine scope.
3330
3440
  const INTERACTIVE_DIRECTIVES = new Set([
3331
- 'x-colors', 'x-dropdown', 'x-tooltip', 'x-tab', 'x-tabpanel',
3441
+ 'x-color', 'x-dropdown', 'x-tooltip', 'x-tab', 'x-tabpanel',
3332
3442
  'x-toast', 'x-carousel', 'x-resize', 'x-anchors', 'x-model',
3333
3443
  'x-files', 'x-data-files',
3334
3444
  ]);
3335
3445
  // Runtime-only Alpine magics whose values change after the prerender
3336
3446
  // snapshot (e.g. via media query, route change, auth state). Bindings
3337
3447
  // referencing these must re-evaluate in the live page.
3338
- const RUNTIME_MAGIC_RX = /(?<!['"])\$(colors|locale|url|auth|search|query|toast)\b/;
3448
+ const RUNTIME_MAGIC_RX = /(?<!['"])\$(color|locale|url|auth|search|query|toast)\b/;
3339
3449
 
3340
3450
  const isDiffBindingAttr = (name) =>
3341
3451
  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.26",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {