mnfst-render 0.5.50 → 0.5.52

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 +748 -5
  2. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { cpus, hostname } from 'node:os';
10
10
  import { createRequire } from 'node:module';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { createHash, randomBytes } from 'node:crypto';
13
+ import { gzipSync, gunzipSync } from 'node:zlib';
13
14
 
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const require = createRequire(import.meta.url);
@@ -408,6 +409,8 @@ function parseArgs() {
408
409
  if (args[i] === '--wait-after-idle' && args[i + 1]) { out.waitAfterIdle = parseInt(args[++i], 10); continue; }
409
410
  if (args[i] === '--concurrency' && args[i + 1]) { out.concurrency = parseInt(args[++i], 10); continue; }
410
411
  if (args[i] === '--retries' && args[i + 1]) { out.retries = parseInt(args[++i], 10); continue; }
412
+ if (args[i] === '--incremental') { out.incremental = true; continue; }
413
+ if (args[i] === '--no-incremental') { out.noIncremental = true; continue; }
411
414
  if (args[i] === '--dry-run') { out.dryRun = true; continue; }
412
415
  if (args[i] === '--force-lock') { out.forceLock = true; continue; }
413
416
  if (args[i] === '--debug-prerender') { out.debugPrerender = true; continue; }
@@ -521,6 +524,25 @@ function expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeR
521
524
  return paths;
522
525
  }
523
526
 
527
+ /**
528
+ * Incremental participation policy. Recording is on whenever the project
529
+ * PARTICIPATES: config `render.incremental: true`, the --incremental flag, or
530
+ * a valid ledger already on disk (so one opted-in render upgrades every later
531
+ * plain run). --no-incremental disables skipping only — a participating run
532
+ * still records and refreshes the ledger. Explicit config `incremental:
533
+ * false` is fully off, ledger or not. Never-opted-in stays fully inert.
534
+ */
535
+ function resolveIncrementalPolicy({ configValue, flagIncremental, flagNoIncremental, ledgerExists }) {
536
+ const participates = flagIncremental === true
537
+ ? true
538
+ : configValue === false ? false : (configValue === true || !!ledgerExists);
539
+ return {
540
+ record: participates,
541
+ skip: participates && flagNoIncremental !== true,
542
+ explicit: flagIncremental === true || configValue === true,
543
+ };
544
+ }
545
+
524
546
  function resolveConfig() {
525
547
  const cli = parseArgs();
526
548
  const cwd = process.cwd();
@@ -577,6 +599,21 @@ function resolveConfig() {
577
599
  paths: Array.isArray(pre.paths)
578
600
  ? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
579
601
  : [],
602
+ // Incremental renders: see resolveIncrementalPolicy. `incremental` gates
603
+ // skipping; `incrementalRecord` gates capture + ledger writes.
604
+ ...(() => {
605
+ const p = resolveIncrementalPolicy({
606
+ configValue: pre.incremental,
607
+ flagIncremental: cli.incremental,
608
+ flagNoIncremental: cli.noIncremental,
609
+ ledgerExists: loadRouteLedger(root) !== null,
610
+ });
611
+ return { incremental: p.skip, incrementalRecord: p.record, incrementalExplicit: p.explicit };
612
+ })(),
613
+ // Requests ABORTED during prerender (substring match, `*` glob within a
614
+ // pattern). blockTelemetry (default on) adds TELEMETRY_BLOCK_PATTERNS.
615
+ blockRequests: Array.isArray(pre.blockRequests) ? pre.blockRequests.map((p) => String(p)).filter(Boolean) : [],
616
+ blockTelemetry: pre.blockTelemetry !== false,
580
617
  dryRun: !!cli.dryRun,
581
618
  forceLock: !!cli.forceLock,
582
619
  debugPrerender: !!cli.debugPrerender,
@@ -3422,6 +3459,538 @@ function startStaticServer(rootDir) {
3422
3459
  });
3423
3460
  }
3424
3461
 
3462
+ // --- Incremental rendering (opt-in) -------------------------------------------
3463
+ // Ledger at <root>/.mnfst-cache/routes.json: per-route input fingerprints from
3464
+ // the last fully successful run. Skipping is only ever an optimization — any
3465
+ // uncertainty (missing file, failed resolution, parse error) falls back to
3466
+ // rendering, never to skipping.
3467
+
3468
+ const LEDGER_SCHEMA_VERSION = 2;
3469
+
3470
+ const sha256Full = (data) => createHash('sha256').update(data).digest('hex');
3471
+
3472
+ /** Deterministic JSON: object keys sorted recursively. */
3473
+ function stableStringify(value) {
3474
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
3475
+ if (value && typeof value === 'object') {
3476
+ return '{' + Object.keys(value).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
3477
+ }
3478
+ return value === undefined ? 'null' : JSON.stringify(value);
3479
+ }
3480
+
3481
+ /** Nearest package.json identity — part of every signature so a renderer
3482
+ * upgrade invalidates the whole ledger. A run from the repo tree (not an
3483
+ * installed mnfst-render) also folds in the script's own content hash, since
3484
+ * the repo version doesn't bump per edit. Unidentifiable → never skip. */
3485
+ function rendererPackageIdentity() {
3486
+ let id = 'dev';
3487
+ for (const rel of ['package.json', '../package.json', '../../package.json']) {
3488
+ try {
3489
+ const pkg = JSON.parse(readFileSync(join(__dirname, rel), 'utf8'));
3490
+ if (pkg && pkg.version) { id = `${pkg.name || 'mnfst-render'}@${pkg.version}`; break; }
3491
+ } catch { /* keep looking */ }
3492
+ }
3493
+ if (!id.startsWith('mnfst-render@')) {
3494
+ try { id += '+' + sha256Full(readFileSync(fileURLToPath(import.meta.url))).slice(0, 16); }
3495
+ catch { id += '+' + Date.now(); }
3496
+ }
3497
+ return id;
3498
+ }
3499
+
3500
+ /** Same pathname → disk-file resolution as startStaticServer (incl. the SPA
3501
+ * index.html fallback); null when nothing would serve the path. */
3502
+ function resolveStaticRequestFile(rootDir, pathname) {
3503
+ const rootResolved = resolve(rootDir);
3504
+ const segments = String(pathname || '/').split('/').filter((s) => s && s !== '..');
3505
+ const rel = (p) => relative(rootResolved, p).split(sep).join('/');
3506
+ let resolvedPath;
3507
+ try { resolvedPath = resolve(join(rootResolved, ...segments)); } catch { resolvedPath = null; }
3508
+ if (resolvedPath && resolvedPath.startsWith(rootResolved)) {
3509
+ try {
3510
+ if (existsSync(resolvedPath)) {
3511
+ const st = statSync(resolvedPath);
3512
+ if (!st.isDirectory()) return rel(resolvedPath);
3513
+ const idx = join(resolvedPath, 'index.html');
3514
+ if (existsSync(idx)) return rel(idx);
3515
+ }
3516
+ } catch { /* fall through to the SPA fallback */ }
3517
+ }
3518
+ return existsSync(join(rootResolved, 'index.html')) ? 'index.html' : null;
3519
+ }
3520
+
3521
+ const PINNED_VERSION_RE = /@\d+\.\d+\.\d+/;
3522
+
3523
+ // Bodies above this are never fingerprinted — the route renders instead.
3524
+ const EXTERNAL_BODY_HASH_LIMIT = 8 * 1024 * 1024;
3525
+
3526
+ // A prerender is not a visitor (Playcom measured 465 fake GA page_views per
3527
+ // render). Collection endpoints only — never script loads.
3528
+ const TELEMETRY_BLOCK_PATTERNS = [
3529
+ 'google-analytics.com/g/collect',
3530
+ '*.google-analytics.com/mp/collect',
3531
+ 'stats.g.doubleclick.net',
3532
+ '*.analytics.google.com/g/collect',
3533
+ 'plausible.io/api/event',
3534
+ 'api.segment.io/v1',
3535
+ 'api.mixpanel.com/track',
3536
+ '.facebook.com/tr?',
3537
+ '.facebook.com/tr/',
3538
+ ];
3539
+
3540
+ /** URL predicate from block patterns: substring match, `*` globs within a
3541
+ * pattern. null when there is nothing to block. */
3542
+ function compileBlockMatcher(patterns) {
3543
+ const regs = [];
3544
+ for (const p of patterns || []) {
3545
+ const src = String(p).split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
3546
+ if (src) regs.push(new RegExp(src));
3547
+ }
3548
+ if (!regs.length) return null;
3549
+ return (url) => regs.some((r) => r.test(String(url)));
3550
+ }
3551
+
3552
+ /** Cross-origin response → { validator } | { flag } | {} (2xx without
3553
+ * validator: content-address the body). A 304 is the page's OWN conditional
3554
+ * revalidation answered "unchanged" — a hit, not a failure: the validator
3555
+ * comes from the 304's headers, else the request's conditional values.
3556
+ * A 304 has no body, so it never falls through to buffer(). */
3557
+ function classifyExternalResponse(status, responseHeaders = {}, requestHeaders = {}) {
3558
+ if (status === 304) {
3559
+ const etag = responseHeaders['etag'] || requestHeaders['if-none-match'];
3560
+ if (etag) return { validator: { etag } };
3561
+ const lastModified = responseHeaders['last-modified'] || requestHeaders['if-modified-since'];
3562
+ if (lastModified) return { validator: { lastModified } };
3563
+ return { flag: 'external 304 without validator' };
3564
+ }
3565
+ if (status < 200 || status >= 300) return { flag: `external ${status}` };
3566
+ const etag = responseHeaders['etag'];
3567
+ const lastModified = responseHeaders['last-modified'];
3568
+ if (etag || lastModified) return { validator: etag ? { etag } : { lastModified } };
3569
+ return {};
3570
+ }
3571
+
3572
+ /**
3573
+ * Incremental input-capture hooks over plain request/response shapes
3574
+ * (Puppeteer-independent for unit tests). Same-origin GETs map to disk files
3575
+ * via the static server's own resolution; anything un-fingerprintable marks
3576
+ * the route non-cacheable. A blocked request never happened: not an input,
3577
+ * not a failure, never awaited.
3578
+ */
3579
+ function createCaptureHooks({ capture, localOrigin, resolveRequest, isBlocked }) {
3580
+ const flag = (reason) => {
3581
+ if (!capture.nonCacheable) { capture.nonCacheable = true; capture.reason = reason; }
3582
+ };
3583
+ return {
3584
+ request({ url, method }) {
3585
+ try {
3586
+ const u = new URL(url);
3587
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
3588
+ if (isBlocked && isBlocked(u.href)) return;
3589
+ if (method !== 'GET') { flag(`non-GET ${method} ${u.href}`); return; }
3590
+ if (u.origin !== localOrigin) {
3591
+ // Pinned-version URL: the URL is its own identity. Anything else
3592
+ // must get a PROCESSED response (validator or content hash) by the
3593
+ // end of the route or it stays in `awaiting` → route renders.
3594
+ if (PINNED_VERSION_RE.test(u.pathname)) capture.externalPinned.add(u.href);
3595
+ else capture.awaiting.add(u.href);
3596
+ return;
3597
+ }
3598
+ const rel = resolveRequest(u.pathname);
3599
+ if (rel) { capture.requested.set(u.pathname, rel); capture.files.add(rel); }
3600
+ else flag(`unresolvable request ${u.pathname}`);
3601
+ } catch { flag('unparseable request URL'); }
3602
+ },
3603
+ requestFailed({ url }) {
3604
+ if (isBlocked && isBlocked(url)) return;
3605
+ try { capture.awaiting.delete(new URL(url).href); } catch { /* flagged below */ }
3606
+ flag(`request failed: ${url}`);
3607
+ },
3608
+ response({ url, status, headers, requestHeaders, buffer }) {
3609
+ try {
3610
+ const u = new URL(url);
3611
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
3612
+ if (isBlocked && isBlocked(u.href)) return;
3613
+ if (u.origin === localOrigin || PINNED_VERSION_RE.test(u.pathname)) {
3614
+ if (status >= 400) flag(`${status} ${u.href}`);
3615
+ return;
3616
+ }
3617
+ const verdict = classifyExternalResponse(status, headers || {}, requestHeaders || {});
3618
+ if (verdict.flag) { capture.awaiting.delete(u.href); flag(`${verdict.flag} ${u.href}`); return; }
3619
+ if (verdict.validator) {
3620
+ capture.externalValidated.set(u.href, verdict.validator);
3621
+ capture.awaiting.delete(u.href);
3622
+ return;
3623
+ }
3624
+ // No validator (e.g. cdn.manifestx.dev): content-address the bytes;
3625
+ // revalidation re-downloads and compares. Unreadable or oversized
3626
+ // body → render. `awaiting` clears only once the hash is recorded.
3627
+ capture.pending.push(
3628
+ Promise.resolve().then(buffer)
3629
+ .then((body) => {
3630
+ if (body.length > EXTERNAL_BODY_HASH_LIMIT) { flag(`external response too large to fingerprint: ${u.href}`); return; }
3631
+ capture.externalValidated.set(u.href, { contentHash: sha256Full(body) });
3632
+ capture.awaiting.delete(u.href);
3633
+ })
3634
+ .catch(() => flag(`external response unreadable: ${u.href}`))
3635
+ );
3636
+ } catch { flag('unparseable response URL'); }
3637
+ },
3638
+ };
3639
+ }
3640
+
3641
+ const externalValidatorToken = (url, v) =>
3642
+ `${url}#etag:${v?.etag || ''}|lm:${v?.lastModified || ''}|ch:${v?.contentHash || ''}`;
3643
+
3644
+ /** Post-drain settlement: an unpinned cross-origin request that never got a
3645
+ * processed response (memory-cache hit, late/unreadable body) is an
3646
+ * UNVERIFIED external dependency — the route must render. */
3647
+ function settleCaptureAwaiting(capture) {
3648
+ if (capture.awaiting && capture.awaiting.size > 0 && !capture.nonCacheable) {
3649
+ capture.nonCacheable = true;
3650
+ capture.reason = `unverified external: ${[...capture.awaiting][0]}`;
3651
+ }
3652
+ return capture;
3653
+ }
3654
+
3655
+ /** Re-check a recorded cross-origin validator. ETag/Last-Modified go out as
3656
+ * a conditional request where only a 304 counts as unchanged; a validator-less
3657
+ * resource was content-addressed at capture, so it is re-downloaded and its
3658
+ * bytes re-hashed. Any other status, a redirect, or a network failure means
3659
+ * render. Memoized per run. */
3660
+ function createExternalRevalidator({ request, download } = {}) {
3661
+ const requestFn = request || (async (url, headers) => {
3662
+ const ctrl = new AbortController();
3663
+ const t = setTimeout(() => ctrl.abort(), 10000);
3664
+ try {
3665
+ const res = await fetch(url, { method: 'GET', headers, redirect: 'manual', signal: ctrl.signal });
3666
+ try { await res.body?.cancel(); } catch { /* no body on 304 */ }
3667
+ return res.status;
3668
+ } finally { clearTimeout(t); }
3669
+ });
3670
+ const downloadFn = download || (async (url) => {
3671
+ const ctrl = new AbortController();
3672
+ const t = setTimeout(() => ctrl.abort(), 20000);
3673
+ try {
3674
+ const res = await fetch(url, { method: 'GET', redirect: 'manual', signal: ctrl.signal });
3675
+ if (res.status < 200 || res.status >= 300) return null;
3676
+ if (Number(res.headers.get('content-length') || 0) > EXTERNAL_BODY_HASH_LIMIT) return null;
3677
+ return Buffer.from(await res.arrayBuffer());
3678
+ } finally { clearTimeout(t); }
3679
+ });
3680
+ const cache = new Map();
3681
+ return (url, validator) => {
3682
+ const key = externalValidatorToken(url, validator);
3683
+ if (!cache.has(key)) {
3684
+ let check;
3685
+ if (validator?.etag || validator?.lastModified) {
3686
+ const headers = validator.etag
3687
+ ? { 'If-None-Match': validator.etag }
3688
+ : { 'If-Modified-Since': validator.lastModified };
3689
+ check = Promise.resolve().then(() => requestFn(url, headers)).then((status) => status === 304);
3690
+ } else if (validator?.contentHash) {
3691
+ check = Promise.resolve().then(() => downloadFn(url))
3692
+ .then((body) => !!body && body.length <= EXTERNAL_BODY_HASH_LIMIT
3693
+ && sha256Full(body) === validator.contentHash);
3694
+ } else {
3695
+ check = Promise.resolve(false);
3696
+ }
3697
+ cache.set(key, check.catch(() => false));
3698
+ }
3699
+ return cache.get(key);
3700
+ };
3701
+ }
3702
+
3703
+ /** relPath acceptable for joining under a base dir. */
3704
+ const isSafeRelPath = (rel) =>
3705
+ typeof rel === 'string' && rel.length > 0 && !rel.includes('\\') && rel[0] !== '/'
3706
+ && !/^[A-Za-z]:/.test(rel) && !rel.split('/').some((s) => s === '..' || s === '');
3707
+
3708
+ function computeRouteSignature({ globalKey, inputPairs, requestedPairs = [], externalTokens }) {
3709
+ return sha256Full([
3710
+ globalKey,
3711
+ [...inputPairs].sort().join('\n'),
3712
+ [...requestedPairs].sort().join('\n'),
3713
+ [...externalTokens].sort().join('\n'),
3714
+ ].join('|'));
3715
+ }
3716
+
3717
+ function routesLedgerPath(rootDir) { return join(rootDir, '.mnfst-cache', 'routes.json'); }
3718
+
3719
+ /** null on absence, parse failure, or schema mismatch — full render. */
3720
+ function loadRouteLedger(rootDir) {
3721
+ try {
3722
+ const parsed = JSON.parse(readFileSync(routesLedgerPath(rootDir), 'utf8'));
3723
+ if (!parsed || parsed.schemaVersion !== LEDGER_SCHEMA_VERSION) return null;
3724
+ if (!parsed.routes || typeof parsed.routes !== 'object') return null;
3725
+ return parsed;
3726
+ } catch { return null; }
3727
+ }
3728
+
3729
+ function rawHtmlCachePath(rootDir, routeKey) {
3730
+ return join(rootDir, '.mnfst-cache', 'render-html', `${sha256Full(String(routeKey)).slice(0, 24)}.html.gz`);
3731
+ }
3732
+
3733
+ /** Disk files loadContentForPrerender reads for `locale` — Node-side inputs
3734
+ * (head meta / x-binding resolution) that the page itself never fetches. */
3735
+ function contentFilesForPrerender(manifest, locale) {
3736
+ const data = manifest?.data?.content;
3737
+ if (!data) return [];
3738
+ const loc = locale || 'en';
3739
+ const strip = (p) => String(p).replace(/^\/+/, '');
3740
+ if (typeof data === 'string' && data.endsWith('.csv')) return [strip(data)];
3741
+ if (data && typeof data === 'object' && typeof data.locales === 'string') return [strip(data.locales)];
3742
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
3743
+ const filePath = data[loc] || data[Object.keys(data)[0]];
3744
+ if (typeof filePath === 'string') return [strip(filePath)];
3745
+ }
3746
+ return [];
3747
+ }
3748
+
3749
+ /**
3750
+ * Per-route skip decision. Skip requires: a well-formed entry, every recorded
3751
+ * request pathname still resolving to the same file (a new physical file can
3752
+ * shadow a prior SPA-fallback resolution), every recorded input re-hashing to
3753
+ * its recorded value NOW, the recomputed signature matching (config/version/
3754
+ * manifest/externals), every recorded output byte-identical in the previous
3755
+ * final output (closes the edit→render-elsewhere→revert hole), and every
3756
+ * cross-origin validator answering 304 to a conditional request NOW.
3757
+ * Dependency-injected for unit tests.
3758
+ */
3759
+ async function evaluateRouteSkip({ entry, hashInput, resolveRequest, revalidateExternal, globalKey, outputHash }) {
3760
+ if (!entry || typeof entry !== 'object') return { skip: false, reason: 'no ledger entry' };
3761
+ if (entry.nonCacheable) return { skip: false, reason: 'non-cacheable' };
3762
+ if (typeof entry.sig !== 'string' || !entry.inputs || typeof entry.inputs !== 'object'
3763
+ || !entry.requested || typeof entry.requested !== 'object'
3764
+ || !Array.isArray(entry.external)
3765
+ || !entry.externalValidated || typeof entry.externalValidated !== 'object'
3766
+ || !entry.outputs || typeof entry.outputs !== 'object'
3767
+ || Object.keys(entry.outputs).length === 0) {
3768
+ return { skip: false, reason: 'malformed entry' };
3769
+ }
3770
+ const requestedPairs = [];
3771
+ for (const [pathname, relPath] of Object.entries(entry.requested)) {
3772
+ if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
3773
+ if (resolveRequest(pathname) !== relPath) return { skip: false, reason: `resolution changed: ${pathname}` };
3774
+ requestedPairs.push(`${pathname}=>${relPath}`);
3775
+ }
3776
+ const inputPairs = [];
3777
+ for (const [relPath, recorded] of Object.entries(entry.inputs)) {
3778
+ if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
3779
+ const now = hashInput(relPath);
3780
+ if (now !== recorded) return { skip: false, reason: `input changed: ${relPath}` };
3781
+ inputPairs.push(`${relPath}:${now}`);
3782
+ }
3783
+ const externalTokens = [
3784
+ ...entry.external,
3785
+ ...Object.entries(entry.externalValidated).map(([u, v]) => externalValidatorToken(u, v)),
3786
+ ];
3787
+ if (computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }) !== entry.sig) {
3788
+ return { skip: false, reason: 'signature mismatch' };
3789
+ }
3790
+ for (const [relPath, recorded] of Object.entries(entry.outputs)) {
3791
+ if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
3792
+ if (outputHash(relPath) !== recorded) return { skip: false, reason: `previous output missing or changed: ${relPath}` };
3793
+ }
3794
+ // Network last: everything local must already agree.
3795
+ for (const [url, validator] of Object.entries(entry.externalValidated)) {
3796
+ if (!(await revalidateExternal(url, validator))) return { skip: false, reason: `external changed: ${url}` };
3797
+ }
3798
+ return { skip: true };
3799
+ }
3800
+
3801
+ /**
3802
+ * Run-scoped incremental state: ledger, memoized hashing/resolution, the skip
3803
+ * pass, per-route capture records, and the end-of-run ledger write.
3804
+ */
3805
+ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps, skipEnabled = true }) {
3806
+ const ledger = loadRouteLedger(rootResolved);
3807
+ const revalidate = createExternalRevalidator();
3808
+ const hashFileAt = (base, relPath) => {
3809
+ try { return sha256Full(readFileSync(join(base, relPath))); } catch { return null; }
3810
+ };
3811
+ // 'absent' is a value, not a miss: a recorded-absent file that stays absent
3812
+ // matches, one that appears mismatches.
3813
+ const skipHashes = new Map();
3814
+ const hashInput = (relPath) => {
3815
+ if (!skipHashes.has(relPath)) skipHashes.set(relPath, hashFileAt(rootResolved, relPath) ?? 'absent');
3816
+ return skipHashes.get(relPath);
3817
+ };
3818
+ const captures = new Map(); // routeKey → capture record of a route rendered this run
3819
+ const carried = new Map(); // routeKey → previous entry revalidated and reused this run
3820
+ const state = { disabled: false, skipped: 0, reasons: {}, skipEnabled };
3821
+ const disable = (reason) => {
3822
+ if (state.disabled) return;
3823
+ state.disabled = true;
3824
+ process.stdout.write(`prerender: incremental disabled for this run (${reason}) — rendering everything\n`);
3825
+ };
3826
+ // Why-rendered buckets for the run summary.
3827
+ const noteRender = (reason, entry) => {
3828
+ const r = String(reason || '');
3829
+ let bucket;
3830
+ if (r === 'non-cacheable') {
3831
+ const detail = String(entry?.reason || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim().replace(/[:\s]+$/, '');
3832
+ bucket = detail ? `non-cacheable: ${detail}` : 'non-cacheable';
3833
+ } else if (r.startsWith('no ledger entry')) bucket = 'new';
3834
+ else if (r.startsWith('input ')) bucket = 'changed inputs';
3835
+ else if (r.startsWith('resolution changed')) bucket = 'changed resolution';
3836
+ else if (r.startsWith('external changed')) bucket = 'changed externals';
3837
+ else if (r.startsWith('signature mismatch')) bucket = 'changed signature';
3838
+ else if (r.startsWith('previous output')) bucket = 'previous output missing/changed';
3839
+ else bucket = r.slice(0, 48) || 'unknown';
3840
+ state.reasons[bucket] = (state.reasons[bucket] || 0) + 1;
3841
+ };
3842
+
3843
+ return {
3844
+ state,
3845
+ captures,
3846
+ recordRoute(routeKey, record) { captures.set(routeKey, record); },
3847
+
3848
+ /** Skip pass: returns the subset of `paths` that must render; copies the
3849
+ * previous outputs of the rest into staging and carries their entries. */
3850
+ async partition(paths) {
3851
+ if (!skipEnabled) return paths; // recording-only run: render all, refresh the ledger
3852
+ if (!ledger) {
3853
+ for (let i = 0; i < paths.length; i++) noteRender('no ledger entry');
3854
+ return paths;
3855
+ }
3856
+ const toRender = [];
3857
+ for (const seg of paths) {
3858
+ if (state.disabled) { noteRender('incremental disabled'); toRender.push(seg); continue; }
3859
+ const entry = ledger.routes[seg];
3860
+ let verdict;
3861
+ try {
3862
+ verdict = await evaluateRouteSkip({
3863
+ entry, hashInput, globalKey,
3864
+ resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
3865
+ revalidateExternal: revalidate,
3866
+ outputHash: (rel) => hashFileAt(previousOutput, rel),
3867
+ });
3868
+ } catch (err) {
3869
+ disable(`skip evaluation failed: ${err?.message || err}`);
3870
+ noteRender('incremental disabled');
3871
+ toRender.push(seg);
3872
+ continue;
3873
+ }
3874
+ // A base whose locale variants substitute needs its cached raw DOM
3875
+ // snapshot back; absent or unreadable → render the base.
3876
+ let rawHtml = null;
3877
+ if (verdict.skip && !entry.promoted && deps.hasPendingVariants(seg)) {
3878
+ try { rawHtml = gunzipSync(readFileSync(rawHtmlCachePath(rootResolved, seg))).toString('utf8'); }
3879
+ catch { rawHtml = null; }
3880
+ if (!rawHtml) verdict = { skip: false, reason: 'raw snapshot missing' };
3881
+ }
3882
+ if (verdict.skip) {
3883
+ try {
3884
+ for (const rel of Object.keys(entry.outputs)) {
3885
+ const dest = join(stagingOutput, rel);
3886
+ mkdirSync(dirname(dest), { recursive: true });
3887
+ cpSync(join(previousOutput, rel), dest);
3888
+ }
3889
+ } catch (err) {
3890
+ verdict = { skip: false, reason: `copy failed: ${err?.message || err}` };
3891
+ }
3892
+ }
3893
+ if (!verdict.skip) { noteRender(verdict.reason, entry); toRender.push(seg); continue; }
3894
+ if (entry.promoted) deps.promoteVariants(seg);
3895
+ else if (rawHtml) deps.cacheRawHtml(seg, rawHtml);
3896
+ if (Array.isArray(entry.utility)) for (const b of entry.utility) deps.pushUtilityBlock(b);
3897
+ carried.set(seg, entry);
3898
+ state.skipped++;
3899
+ process.stdout.write(` [skip] ${seg === '' ? '/' : '/' + seg} (unchanged)\n`);
3900
+ }
3901
+ return toRender;
3902
+ },
3903
+
3904
+ /** Written atomically, only after the staged output replaced the previous
3905
+ * one — covers exactly this run's skipped + successfully rendered routes.
3906
+ * Inputs are re-hashed HERE, not from the skip pass's memo, so a mid-run
3907
+ * edit is recorded as it is now and a later revert can't skip-match. */
3908
+ async writeLedger(finalOutput) {
3909
+ if (state.disabled) return false;
3910
+ const writeHashes = new Map();
3911
+ const hashNow = (rel) => {
3912
+ if (!writeHashes.has(rel)) writeHashes.set(rel, hashFileAt(rootResolved, rel) ?? 'absent');
3913
+ return writeHashes.get(rel);
3914
+ };
3915
+ const routes = {};
3916
+ for (const [key, entry] of carried) routes[key] = entry;
3917
+ for (const [key, cap] of captures) {
3918
+ if (cap.nonCacheable) { routes[key] = { nonCacheable: true, reason: cap.reason }; continue; }
3919
+ const requested = {};
3920
+ const requestedPairs = [];
3921
+ for (const pathname of [...cap.requested.keys()].sort()) {
3922
+ const rel = cap.requested.get(pathname);
3923
+ requested[pathname] = rel;
3924
+ requestedPairs.push(`${pathname}=>${rel}`);
3925
+ }
3926
+ const inputs = {};
3927
+ const inputPairs = [];
3928
+ for (const rel of [...cap.files].sort()) {
3929
+ const h = hashNow(rel);
3930
+ inputs[rel] = h;
3931
+ inputPairs.push(`${rel}:${h}`);
3932
+ }
3933
+ const outputs = {};
3934
+ let missingOut = null;
3935
+ for (const rel of cap.outputs) {
3936
+ const h = hashFileAt(finalOutput, rel);
3937
+ if (!h) { missingOut = rel; break; }
3938
+ outputs[rel] = h;
3939
+ }
3940
+ if (missingOut) { routes[key] = { nonCacheable: true, reason: `output unreadable: ${missingOut}` }; continue; }
3941
+ const external = [...cap.externalPinned].sort();
3942
+ const externalValidated = {};
3943
+ const externalTokens = [...external];
3944
+ for (const url of [...cap.externalValidated.keys()].sort()) {
3945
+ const v = cap.externalValidated.get(url);
3946
+ externalValidated[url] = v;
3947
+ externalTokens.push(externalValidatorToken(url, v));
3948
+ }
3949
+ const entry = {
3950
+ sig: computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }),
3951
+ inputs, requested, external, externalValidated, outputs,
3952
+ };
3953
+ if (Array.isArray(cap.utility) && cap.utility.length) entry.utility = cap.utility;
3954
+ if (deps.isPromotedBase(key)) {
3955
+ entry.promoted = true;
3956
+ } else if (deps.hasPendingVariants(key)) {
3957
+ const raw = deps.getRawHtml(key);
3958
+ if (!raw) { routes[key] = { nonCacheable: true, reason: 'raw snapshot unavailable' }; continue; }
3959
+ const p = rawHtmlCachePath(rootResolved, key);
3960
+ mkdirSync(dirname(p), { recursive: true });
3961
+ writeFileSync(p, gzipSync(Buffer.from(raw, 'utf8')));
3962
+ }
3963
+ routes[key] = entry;
3964
+ }
3965
+ const target = routesLedgerPath(rootResolved);
3966
+ mkdirSync(dirname(target), { recursive: true });
3967
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
3968
+ writeFileSync(tmp, JSON.stringify({
3969
+ schemaVersion: LEDGER_SCHEMA_VERSION,
3970
+ createdAt: new Date().toISOString(),
3971
+ routes,
3972
+ }) + '\n', 'utf8');
3973
+ renameSync(tmp, target);
3974
+ // GC: snapshots no route references, and orphaned ledger tmp files.
3975
+ try {
3976
+ const keep = new Set(Object.keys(routes).map((k) => basename(rawHtmlCachePath(rootResolved, k))));
3977
+ const snapDir = join(rootResolved, '.mnfst-cache', 'render-html');
3978
+ if (existsSync(snapDir)) {
3979
+ for (const f of readdirSync(snapDir)) {
3980
+ if (!keep.has(f)) { try { unlinkSync(join(snapDir, f)); } catch { /* in use elsewhere */ } }
3981
+ }
3982
+ }
3983
+ for (const f of readdirSync(join(rootResolved, '.mnfst-cache'))) {
3984
+ if (f.startsWith('routes.json.') && f.endsWith('.tmp')) {
3985
+ try { unlinkSync(join(rootResolved, '.mnfst-cache', f)); } catch { /* best-effort */ }
3986
+ }
3987
+ }
3988
+ } catch { /* GC is best-effort */ }
3989
+ return true;
3990
+ },
3991
+ };
3992
+ }
3993
+
3425
3994
  // --- Output lock (one render per output dir) ---------------------------------
3426
3995
 
3427
3996
  const LOCK_SUFFIX = '.mnfst-lock';
@@ -3980,7 +4549,11 @@ async function runPrerender(config) {
3980
4549
  * segment). When it does, every locale variant of this base is moved from
3981
4550
  * substitution to a real per-locale Puppeteer render.
3982
4551
  */
4552
+ // Bases whose locale variants were promoted this run — persisted in the
4553
+ // route ledger so a future skipped run re-promotes instead of substituting.
4554
+ const promotedBaseSegs = new Set();
3983
4555
  function promoteLocaleVariants(basePathSeg) {
4556
+ promotedBaseSegs.add(basePathSeg);
3984
4557
  const variants = variantsByBase.get(basePathSeg);
3985
4558
  if (!variants || !variants.length) return;
3986
4559
  process.stdout.write(
@@ -4010,9 +4583,83 @@ async function runPrerender(config) {
4010
4583
 
4011
4584
  // baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
4012
4585
  const baseHtmlCache = new Map();
4013
- const puppeteerTotal = puppeteerPaths.length;
4014
4586
 
4015
- process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution)...\n`);
4587
+ // --- Incremental skip pass (see the incremental-rendering section) ----------
4588
+ // Skips reuse files from the PREVIOUS final output, which still exists while
4589
+ // this run builds into staging. Recording runs whenever the project
4590
+ // participates (incrementalRecord); skipping only when `incremental` is on.
4591
+ // Never-opted-in → no capture, no ledger, no new work.
4592
+ let inc = null;
4593
+ if (config.incrementalRecord && !config.serve) {
4594
+ // Input capture maps request pathnames to disk via the built-in server's
4595
+ // resolution; an external --local server voids that mapping.
4596
+ if (config.incrementalExplicit) {
4597
+ process.stdout.write('prerender: incremental needs the built-in static server — ignoring it for this run\n');
4598
+ }
4599
+ } else if (config.incrementalRecord) {
4600
+ if (!config.incrementalExplicit) {
4601
+ process.stdout.write('prerender: incremental active from existing ledger (.mnfst-cache/routes.json; --no-incremental to disable)\n');
4602
+ }
4603
+ let manifestContentHash = 'missing';
4604
+ try { manifestContentHash = sha256Full(readFileSync(join(config.root, 'manifest.json'))); } catch { /* missing */ }
4605
+ // Output-relevant resolved options (CLI included). pathList is in here
4606
+ // because per-page breadcrumb structured data depends on the full set.
4607
+ const optionsSalt = sha256Full(stableStringify({
4608
+ wait: config.wait,
4609
+ waitAfterIdle: config.waitAfterIdle,
4610
+ routerBase: config.routerBase,
4611
+ localeRouteExclude: config.localeRouteExclude,
4612
+ locales: config.locales ?? null,
4613
+ redirects: config.redirects,
4614
+ localeSubstitution: config.localeSubstitution,
4615
+ localeSubstitutionExclude: config.localeSubstitutionExclude,
4616
+ localeSubstitutionExcludePaths: config.localeSubstitutionExcludePaths,
4617
+ paths: config.paths,
4618
+ lazyIcons: config.lazyIcons,
4619
+ seo: config.seo,
4620
+ liveUrl: config.liveUrl,
4621
+ utilitiesBundle: bundleUtilities,
4622
+ tailwindBuilt,
4623
+ // Blocking changes what a page loads; entries from another regime must not skip.
4624
+ blockRequests: [...(config.blockRequests || [])].sort(),
4625
+ blockTelemetry: config.blockTelemetry,
4626
+ pathList: [...pathList].sort(),
4627
+ }));
4628
+ inc = createIncrementalRun({
4629
+ rootResolved,
4630
+ previousOutput: finalOutput,
4631
+ stagingOutput: outputResolved,
4632
+ globalKey: `${LEDGER_SCHEMA_VERSION}|${rendererPackageIdentity()}|${optionsSalt}|${manifestContentHash}`,
4633
+ deps: {
4634
+ hasPendingVariants: (seg) => variantsByBase.has(seg),
4635
+ promoteVariants: promoteLocaleVariants,
4636
+ isPromotedBase: (seg) => promotedBaseSegs.has(seg),
4637
+ cacheRawHtml: (seg, html) => baseHtmlCache.set(seg, html),
4638
+ getRawHtml: (seg) => baseHtmlCache.get(seg),
4639
+ pushUtilityBlock: (b) => utilityBlocks.push(b),
4640
+ },
4641
+ skipEnabled: config.incremental,
4642
+ });
4643
+ }
4644
+
4645
+ // --- Request blocking during prerender ---------------------------------------
4646
+ // Telemetry collection endpoints are blocked by default (blockTelemetry:
4647
+ // false opts out); render.blockRequests adds user patterns. Blocked
4648
+ // requests are aborted via CDP and are invisible to incremental capture.
4649
+ const blockPatterns = [
4650
+ ...(config.blockTelemetry ? TELEMETRY_BLOCK_PATTERNS : []),
4651
+ ...(config.blockRequests || []),
4652
+ ];
4653
+ const blockMatcher = compileBlockMatcher(blockPatterns);
4654
+ const telemetryMatcher = config.blockTelemetry ? compileBlockMatcher(TELEMETRY_BLOCK_PATTERNS) : null;
4655
+ const cdpBlockedUrls = blockPatterns.map((p) => `*${p}*`);
4656
+ let telemetryBlockedCount = 0;
4657
+
4658
+ const puppeteerRenderPaths = inc ? await inc.partition(puppeteerPaths) : puppeteerPaths;
4659
+ let incRendered = puppeteerRenderPaths.length;
4660
+ const puppeteerTotal = puppeteerRenderPaths.length;
4661
+
4662
+ process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution${inc ? `, ${inc.state.skipped} skipped unchanged` : ''})...\n`);
4016
4663
 
4017
4664
  // Asset-wide fingerprint used as a cache-invalidator for OG snapshots:
4018
4665
  // changes to theme CSS, manifest config, or the root index.html mean every
@@ -4052,6 +4699,33 @@ async function runPrerender(config) {
4052
4699
  // live for the whole call.
4053
4700
  const page = await browser.newPage();
4054
4701
  if (attempt) attempt.cancel = () => page.close();
4702
+ // Incremental input capture: same-origin GETs map to disk files via the
4703
+ // static server's own resolution; anything un-fingerprintable marks the
4704
+ // route non-cacheable (it will always render).
4705
+ let capture = null;
4706
+ if (inc && !inc.state.disabled) {
4707
+ capture = {
4708
+ requested: new Map(), files: new Set(),
4709
+ externalPinned: new Set(), externalValidated: new Map(),
4710
+ awaiting: new Set(), pending: [], nonCacheable: false, reason: null,
4711
+ };
4712
+ const hooks = createCaptureHooks({
4713
+ capture,
4714
+ localOrigin: new URL(config.localUrl).origin,
4715
+ resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
4716
+ isBlocked: blockMatcher,
4717
+ });
4718
+ page.on('request', (req) => hooks.request({ url: req.url(), method: req.method() }));
4719
+ page.on('requestfailed', (req) => hooks.requestFailed({ url: req.url() }));
4720
+ page.on('response', (res) => hooks.response({
4721
+ url: res.url(), status: res.status(), headers: res.headers(),
4722
+ requestHeaders: res.request().headers(),
4723
+ buffer: () => res.buffer(),
4724
+ }));
4725
+ }
4726
+ if (telemetryMatcher) {
4727
+ page.on('requestfailed', (req) => { if (telemetryMatcher(req.url())) telemetryBlockedCount++; });
4728
+ }
4055
4729
  // Render at a typical desktop viewport so layouts dependent on viewport
4056
4730
  // width (responsive flex/grid, container queries, media queries) settle
4057
4731
  // into their desktop variant. Without this the headless default (often
@@ -4060,6 +4734,13 @@ async function runPrerender(config) {
4060
4734
  // viewport-driven flex distribution.
4061
4735
  await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 });
4062
4736
  try {
4737
+ // CDP URL blocking (telemetry + render.blockRequests): requests abort at
4738
+ // the network layer; coexists with the page.on capture listeners above.
4739
+ if (cdpBlockedUrls.length > 0) {
4740
+ const cdp = await page.createCDPSession();
4741
+ await cdp.send('Network.enable');
4742
+ await cdp.send('Network.setBlockedURLs', { urls: cdpBlockedUrls });
4743
+ }
4063
4744
  // Align <html lang> with the URL being prerendered before any app script runs.
4064
4745
  // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
4065
4746
  // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
@@ -5328,9 +6009,11 @@ async function runPrerender(config) {
5328
6009
  html = stripDataTailwindAttr(html);
5329
6010
  }
5330
6011
  html = debakeThemeClass(html);
6012
+ let routeUtilityBlocks = [];
5331
6013
  if (bundleUtilities) {
5332
6014
  const extracted = extractUtilityStyleBlocks(html);
5333
6015
  html = extracted.html;
6016
+ routeUtilityBlocks = extracted.blocks;
5334
6017
  for (const b of extracted.blocks) utilityBlocks.push(b);
5335
6018
  }
5336
6019
  if (tailwindBuilt) {
@@ -5387,6 +6070,24 @@ async function runPrerender(config) {
5387
6070
  if (attempt?.abandoned) return; // a retry owns the output now
5388
6071
  mkdirSync(outDir, { recursive: true });
5389
6072
  writeFileSync(outFile, html, 'utf8');
6073
+ if (capture) {
6074
+ await Promise.allSettled(capture.pending); // drain in-flight body hashes
6075
+ settleCaptureAwaiting(capture); // anything still pending → render
6076
+ const outputs = [relative(config.output, outFile).split(sep).join('/')];
6077
+ if (earlySnapshotUrl) outputs.push(String(earlySnapshotUrl).replace(/^\//, ''));
6078
+ // Node-side head/meta inputs the page never fetches.
6079
+ for (const rel of contentFilesForPrerender(manifest, currentLocale)) capture.files.add(rel);
6080
+ inc.recordRoute(pathSeg, {
6081
+ requested: capture.requested,
6082
+ files: capture.files,
6083
+ externalPinned: capture.externalPinned,
6084
+ externalValidated: capture.externalValidated,
6085
+ nonCacheable: capture.nonCacheable,
6086
+ reason: capture.reason,
6087
+ outputs,
6088
+ utility: routeUtilityBlocks,
6089
+ });
6090
+ }
5390
6091
  pushDebug({
5391
6092
  path: displayPath,
5392
6093
  stage: 'wrote',
@@ -5436,13 +6137,17 @@ async function runPrerender(config) {
5436
6137
  }
5437
6138
 
5438
6139
  try {
5439
- await renderPuppeteerPaths(puppeteerPaths);
6140
+ await renderPuppeteerPaths(puppeteerRenderPaths);
5440
6141
  // Any base page found to have per-locale runtime content during the pass
5441
6142
  // above gets its locale variants rendered here — real Puppeteer render
5442
6143
  // per locale instead of the (incorrect) text substitution.
5443
6144
  if (promotedPaths.length > 0) {
5444
- process.stdout.write(`prerender: rendering ${promotedPaths.length} promoted locale variant(s) via Puppeteer...\n`);
5445
- await renderPuppeteerPaths(promotedPaths);
6145
+ const promotedToRender = inc ? await inc.partition(promotedPaths) : promotedPaths;
6146
+ incRendered += promotedToRender.length;
6147
+ if (promotedToRender.length > 0) {
6148
+ process.stdout.write(`prerender: rendering ${promotedToRender.length} promoted locale variant(s) via Puppeteer...\n`);
6149
+ await renderPuppeteerPaths(promotedToRender);
6150
+ }
5446
6151
  }
5447
6152
  } finally {
5448
6153
  await closeBrowser(browser);
@@ -5490,6 +6195,18 @@ async function runPrerender(config) {
5490
6195
  throw new Error(`prerender failed for ${failedPaths.length}/${pathTotal} paths. Sample: ${sample}`);
5491
6196
  }
5492
6197
 
6198
+ if (inc) {
6199
+ if (!inc.state.skipEnabled) {
6200
+ process.stdout.write(`prerender: rendered ${incRendered} (ledger refreshed, skipping off)\n`);
6201
+ } else {
6202
+ const why = Object.entries(inc.state.reasons).sort((a, b) => b[1] - a[1]).map(([r, c]) => `${c} ${r}`);
6203
+ process.stdout.write(`prerender: skipped ${inc.state.skipped} unchanged, rendered ${incRendered}${why.length ? ` (${why.join(', ')})` : ''}\n`);
6204
+ }
6205
+ }
6206
+ if (telemetryBlockedCount > 0) {
6207
+ process.stdout.write(`prerender: blocked ${telemetryBlockedCount} telemetry requests (render.blockTelemetry: false to allow)\n`);
6208
+ }
6209
+
5493
6210
  if (config.debugPrerender) {
5494
6211
  const reportPath = join(outputResolved, 'prerender.debug.json');
5495
6212
  writeFileSync(reportPath, JSON.stringify({
@@ -5555,6 +6272,16 @@ async function runPrerender(config) {
5555
6272
  if (existsSync(finalOutput)) rmSync(finalOutput, { recursive: true });
5556
6273
  renameSync(stagingOutput, finalOutput);
5557
6274
  config.output = finalOutput;
6275
+
6276
+ // Ledger only after the swap — a run that failed anywhere above never
6277
+ // records anything, so the next run re-renders whatever is uncertain.
6278
+ if (inc) {
6279
+ try {
6280
+ await inc.writeLedger(finalOutput);
6281
+ } catch (err) {
6282
+ process.stderr.write(`prerender: incremental ledger not written (${err?.message || err})\n`);
6283
+ }
6284
+ }
5558
6285
  }
5559
6286
 
5560
6287
  // Auto-run main() when this file is the direct entry point (node manifest.render.mjs)
@@ -5593,4 +6320,20 @@ export {
5593
6320
  isLocaleSubstitutionExcludedPath,
5594
6321
  loadAllLocaleContentData,
5595
6322
  buildSubstitutionPairs,
6323
+ LEDGER_SCHEMA_VERSION,
6324
+ stableStringify,
6325
+ computeRouteSignature,
6326
+ evaluateRouteSkip,
6327
+ loadRouteLedger,
6328
+ resolveStaticRequestFile,
6329
+ createExternalRevalidator,
6330
+ contentFilesForPrerender,
6331
+ isSafeRelPath,
6332
+ settleCaptureAwaiting,
6333
+ EXTERNAL_BODY_HASH_LIMIT,
6334
+ resolveIncrementalPolicy,
6335
+ classifyExternalResponse,
6336
+ createCaptureHooks,
6337
+ compileBlockMatcher,
6338
+ TELEMETRY_BLOCK_PATTERNS,
5596
6339
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.50",
3
+ "version": "0.5.52",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {