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 +1 -1
- package/manifest.render.mjs +121 -11
- 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);
|
|
@@ -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
|
|
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="
|
|
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=["']
|
|
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=["']
|
|
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.
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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-
|
|
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 = /(?<!['"])\$(
|
|
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' ||
|