mnfst-render 0.5.49 → 0.5.51

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 +609 -7
  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; }
@@ -577,6 +580,9 @@ function resolveConfig() {
577
580
  paths: Array.isArray(pre.paths)
578
581
  ? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
579
582
  : [],
583
+ // Incremental renders (opt-in): skip Puppeteer routes whose fingerprinted
584
+ // inputs are unchanged since the last successful run. --no-incremental wins.
585
+ incremental: cli.noIncremental ? false : !!(cli.incremental ?? pre.incremental ?? false),
580
586
  dryRun: !!cli.dryRun,
581
587
  forceLock: !!cli.forceLock,
582
588
  debugPrerender: !!cli.debugPrerender,
@@ -1480,7 +1486,15 @@ function stripResolvedXIconDirectives(html) {
1480
1486
  }
1481
1487
 
1482
1488
  function markPrerenderedManifestComponents(html) {
1483
- return html.replace(/<(x-[a-z][\w-]*)([^>]*)>/gi, (full, tag, attrs) => {
1489
+ // x-if/x-for template content is authored source Alpine clones at runtime — never mark it.
1490
+ const templates = [];
1491
+ return html.replace(/<(\/?)template(?=[\s>])([^>]*)>|<(x-[a-z][\w-]*)([^>]*)>/gi, (full, close, tplAttrs, tag, attrs) => {
1492
+ if (!tag) {
1493
+ if (close) templates.pop();
1494
+ else templates.push(/\sx-(?:if|for)\s*=/i.test(tplAttrs || ''));
1495
+ return full;
1496
+ }
1497
+ if (templates.includes(true)) return full;
1484
1498
  const a = attrs || '';
1485
1499
  if (/\bdata-pre-rendered\s*=/i.test(a) || /\bdata-processed\s*=/i.test(a)) return full;
1486
1500
  // Inside an explicit hydrate island — the runtime will restore its
@@ -3414,6 +3428,422 @@ function startStaticServer(rootDir) {
3414
3428
  });
3415
3429
  }
3416
3430
 
3431
+ // --- Incremental rendering (opt-in) -------------------------------------------
3432
+ // Ledger at <root>/.mnfst-cache/routes.json: per-route input fingerprints from
3433
+ // the last fully successful run. Skipping is only ever an optimization — any
3434
+ // uncertainty (missing file, failed resolution, parse error) falls back to
3435
+ // rendering, never to skipping.
3436
+
3437
+ const LEDGER_SCHEMA_VERSION = 2;
3438
+
3439
+ const sha256Full = (data) => createHash('sha256').update(data).digest('hex');
3440
+
3441
+ /** Deterministic JSON: object keys sorted recursively. */
3442
+ function stableStringify(value) {
3443
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
3444
+ if (value && typeof value === 'object') {
3445
+ return '{' + Object.keys(value).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
3446
+ }
3447
+ return value === undefined ? 'null' : JSON.stringify(value);
3448
+ }
3449
+
3450
+ /** Nearest package.json identity — part of every signature so a renderer
3451
+ * upgrade invalidates the whole ledger. A run from the repo tree (not an
3452
+ * installed mnfst-render) also folds in the script's own content hash, since
3453
+ * the repo version doesn't bump per edit. Unidentifiable → never skip. */
3454
+ function rendererPackageIdentity() {
3455
+ let id = 'dev';
3456
+ for (const rel of ['package.json', '../package.json', '../../package.json']) {
3457
+ try {
3458
+ const pkg = JSON.parse(readFileSync(join(__dirname, rel), 'utf8'));
3459
+ if (pkg && pkg.version) { id = `${pkg.name || 'mnfst-render'}@${pkg.version}`; break; }
3460
+ } catch { /* keep looking */ }
3461
+ }
3462
+ if (!id.startsWith('mnfst-render@')) {
3463
+ try { id += '+' + sha256Full(readFileSync(fileURLToPath(import.meta.url))).slice(0, 16); }
3464
+ catch { id += '+' + Date.now(); }
3465
+ }
3466
+ return id;
3467
+ }
3468
+
3469
+ /** Same pathname → disk-file resolution as startStaticServer (incl. the SPA
3470
+ * index.html fallback); null when nothing would serve the path. */
3471
+ function resolveStaticRequestFile(rootDir, pathname) {
3472
+ const rootResolved = resolve(rootDir);
3473
+ const segments = String(pathname || '/').split('/').filter((s) => s && s !== '..');
3474
+ const rel = (p) => relative(rootResolved, p).split(sep).join('/');
3475
+ let resolvedPath;
3476
+ try { resolvedPath = resolve(join(rootResolved, ...segments)); } catch { resolvedPath = null; }
3477
+ if (resolvedPath && resolvedPath.startsWith(rootResolved)) {
3478
+ try {
3479
+ if (existsSync(resolvedPath)) {
3480
+ const st = statSync(resolvedPath);
3481
+ if (!st.isDirectory()) return rel(resolvedPath);
3482
+ const idx = join(resolvedPath, 'index.html');
3483
+ if (existsSync(idx)) return rel(idx);
3484
+ }
3485
+ } catch { /* fall through to the SPA fallback */ }
3486
+ }
3487
+ return existsSync(join(rootResolved, 'index.html')) ? 'index.html' : null;
3488
+ }
3489
+
3490
+ const PINNED_VERSION_RE = /@\d+\.\d+\.\d+/;
3491
+
3492
+ // Bodies above this are never fingerprinted — the route renders instead.
3493
+ const EXTERNAL_BODY_HASH_LIMIT = 8 * 1024 * 1024;
3494
+
3495
+ const externalValidatorToken = (url, v) =>
3496
+ `${url}#etag:${v?.etag || ''}|lm:${v?.lastModified || ''}|ch:${v?.contentHash || ''}`;
3497
+
3498
+ /** Post-drain settlement: an unpinned cross-origin request that never got a
3499
+ * processed response (memory-cache hit, late/unreadable body) is an
3500
+ * UNVERIFIED external dependency — the route must render. */
3501
+ function settleCaptureAwaiting(capture) {
3502
+ if (capture.awaiting && capture.awaiting.size > 0 && !capture.nonCacheable) {
3503
+ capture.nonCacheable = true;
3504
+ capture.reason = `unverified external: ${[...capture.awaiting][0]}`;
3505
+ }
3506
+ return capture;
3507
+ }
3508
+
3509
+ /** Re-check a recorded cross-origin validator. ETag/Last-Modified go out as
3510
+ * a conditional request where only a 304 counts as unchanged; a validator-less
3511
+ * resource was content-addressed at capture, so it is re-downloaded and its
3512
+ * bytes re-hashed. Any other status, a redirect, or a network failure means
3513
+ * render. Memoized per run. */
3514
+ function createExternalRevalidator({ request, download } = {}) {
3515
+ const requestFn = request || (async (url, headers) => {
3516
+ const ctrl = new AbortController();
3517
+ const t = setTimeout(() => ctrl.abort(), 10000);
3518
+ try {
3519
+ const res = await fetch(url, { method: 'GET', headers, redirect: 'manual', signal: ctrl.signal });
3520
+ try { await res.body?.cancel(); } catch { /* no body on 304 */ }
3521
+ return res.status;
3522
+ } finally { clearTimeout(t); }
3523
+ });
3524
+ const downloadFn = download || (async (url) => {
3525
+ const ctrl = new AbortController();
3526
+ const t = setTimeout(() => ctrl.abort(), 20000);
3527
+ try {
3528
+ const res = await fetch(url, { method: 'GET', redirect: 'manual', signal: ctrl.signal });
3529
+ if (res.status < 200 || res.status >= 300) return null;
3530
+ if (Number(res.headers.get('content-length') || 0) > EXTERNAL_BODY_HASH_LIMIT) return null;
3531
+ return Buffer.from(await res.arrayBuffer());
3532
+ } finally { clearTimeout(t); }
3533
+ });
3534
+ const cache = new Map();
3535
+ return (url, validator) => {
3536
+ const key = externalValidatorToken(url, validator);
3537
+ if (!cache.has(key)) {
3538
+ let check;
3539
+ if (validator?.etag || validator?.lastModified) {
3540
+ const headers = validator.etag
3541
+ ? { 'If-None-Match': validator.etag }
3542
+ : { 'If-Modified-Since': validator.lastModified };
3543
+ check = Promise.resolve().then(() => requestFn(url, headers)).then((status) => status === 304);
3544
+ } else if (validator?.contentHash) {
3545
+ check = Promise.resolve().then(() => downloadFn(url))
3546
+ .then((body) => !!body && body.length <= EXTERNAL_BODY_HASH_LIMIT
3547
+ && sha256Full(body) === validator.contentHash);
3548
+ } else {
3549
+ check = Promise.resolve(false);
3550
+ }
3551
+ cache.set(key, check.catch(() => false));
3552
+ }
3553
+ return cache.get(key);
3554
+ };
3555
+ }
3556
+
3557
+ /** relPath acceptable for joining under a base dir. */
3558
+ const isSafeRelPath = (rel) =>
3559
+ typeof rel === 'string' && rel.length > 0 && !rel.includes('\\') && rel[0] !== '/'
3560
+ && !/^[A-Za-z]:/.test(rel) && !rel.split('/').some((s) => s === '..' || s === '');
3561
+
3562
+ function computeRouteSignature({ globalKey, inputPairs, requestedPairs = [], externalTokens }) {
3563
+ return sha256Full([
3564
+ globalKey,
3565
+ [...inputPairs].sort().join('\n'),
3566
+ [...requestedPairs].sort().join('\n'),
3567
+ [...externalTokens].sort().join('\n'),
3568
+ ].join('|'));
3569
+ }
3570
+
3571
+ function routesLedgerPath(rootDir) { return join(rootDir, '.mnfst-cache', 'routes.json'); }
3572
+
3573
+ /** null on absence, parse failure, or schema mismatch — full render. */
3574
+ function loadRouteLedger(rootDir) {
3575
+ try {
3576
+ const parsed = JSON.parse(readFileSync(routesLedgerPath(rootDir), 'utf8'));
3577
+ if (!parsed || parsed.schemaVersion !== LEDGER_SCHEMA_VERSION) return null;
3578
+ if (!parsed.routes || typeof parsed.routes !== 'object') return null;
3579
+ return parsed;
3580
+ } catch { return null; }
3581
+ }
3582
+
3583
+ function rawHtmlCachePath(rootDir, routeKey) {
3584
+ return join(rootDir, '.mnfst-cache', 'render-html', `${sha256Full(String(routeKey)).slice(0, 24)}.html.gz`);
3585
+ }
3586
+
3587
+ /** Disk files loadContentForPrerender reads for `locale` — Node-side inputs
3588
+ * (head meta / x-binding resolution) that the page itself never fetches. */
3589
+ function contentFilesForPrerender(manifest, locale) {
3590
+ const data = manifest?.data?.content;
3591
+ if (!data) return [];
3592
+ const loc = locale || 'en';
3593
+ const strip = (p) => String(p).replace(/^\/+/, '');
3594
+ if (typeof data === 'string' && data.endsWith('.csv')) return [strip(data)];
3595
+ if (data && typeof data === 'object' && typeof data.locales === 'string') return [strip(data.locales)];
3596
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
3597
+ const filePath = data[loc] || data[Object.keys(data)[0]];
3598
+ if (typeof filePath === 'string') return [strip(filePath)];
3599
+ }
3600
+ return [];
3601
+ }
3602
+
3603
+ /**
3604
+ * Per-route skip decision. Skip requires: a well-formed entry, every recorded
3605
+ * request pathname still resolving to the same file (a new physical file can
3606
+ * shadow a prior SPA-fallback resolution), every recorded input re-hashing to
3607
+ * its recorded value NOW, the recomputed signature matching (config/version/
3608
+ * manifest/externals), every recorded output byte-identical in the previous
3609
+ * final output (closes the edit→render-elsewhere→revert hole), and every
3610
+ * cross-origin validator answering 304 to a conditional request NOW.
3611
+ * Dependency-injected for unit tests.
3612
+ */
3613
+ async function evaluateRouteSkip({ entry, hashInput, resolveRequest, revalidateExternal, globalKey, outputHash }) {
3614
+ if (!entry || typeof entry !== 'object') return { skip: false, reason: 'no ledger entry' };
3615
+ if (entry.nonCacheable) return { skip: false, reason: 'non-cacheable' };
3616
+ if (typeof entry.sig !== 'string' || !entry.inputs || typeof entry.inputs !== 'object'
3617
+ || !entry.requested || typeof entry.requested !== 'object'
3618
+ || !Array.isArray(entry.external)
3619
+ || !entry.externalValidated || typeof entry.externalValidated !== 'object'
3620
+ || !entry.outputs || typeof entry.outputs !== 'object'
3621
+ || Object.keys(entry.outputs).length === 0) {
3622
+ return { skip: false, reason: 'malformed entry' };
3623
+ }
3624
+ const requestedPairs = [];
3625
+ for (const [pathname, relPath] of Object.entries(entry.requested)) {
3626
+ if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
3627
+ if (resolveRequest(pathname) !== relPath) return { skip: false, reason: `resolution changed: ${pathname}` };
3628
+ requestedPairs.push(`${pathname}=>${relPath}`);
3629
+ }
3630
+ const inputPairs = [];
3631
+ for (const [relPath, recorded] of Object.entries(entry.inputs)) {
3632
+ if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
3633
+ const now = hashInput(relPath);
3634
+ if (now !== recorded) return { skip: false, reason: `input changed: ${relPath}` };
3635
+ inputPairs.push(`${relPath}:${now}`);
3636
+ }
3637
+ const externalTokens = [
3638
+ ...entry.external,
3639
+ ...Object.entries(entry.externalValidated).map(([u, v]) => externalValidatorToken(u, v)),
3640
+ ];
3641
+ if (computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }) !== entry.sig) {
3642
+ return { skip: false, reason: 'signature mismatch' };
3643
+ }
3644
+ for (const [relPath, recorded] of Object.entries(entry.outputs)) {
3645
+ if (!isSafeRelPath(relPath)) return { skip: false, reason: `unsafe path in entry: ${relPath}` };
3646
+ if (outputHash(relPath) !== recorded) return { skip: false, reason: `previous output missing or changed: ${relPath}` };
3647
+ }
3648
+ // Network last: everything local must already agree.
3649
+ for (const [url, validator] of Object.entries(entry.externalValidated)) {
3650
+ if (!(await revalidateExternal(url, validator))) return { skip: false, reason: `external changed: ${url}` };
3651
+ }
3652
+ return { skip: true };
3653
+ }
3654
+
3655
+ /**
3656
+ * Run-scoped incremental state: ledger, memoized hashing/resolution, the skip
3657
+ * pass, per-route capture records, and the end-of-run ledger write.
3658
+ */
3659
+ function createIncrementalRun({ rootResolved, previousOutput, stagingOutput, globalKey, deps }) {
3660
+ const ledger = loadRouteLedger(rootResolved);
3661
+ const revalidate = createExternalRevalidator();
3662
+ const hashFileAt = (base, relPath) => {
3663
+ try { return sha256Full(readFileSync(join(base, relPath))); } catch { return null; }
3664
+ };
3665
+ // 'absent' is a value, not a miss: a recorded-absent file that stays absent
3666
+ // matches, one that appears mismatches.
3667
+ const skipHashes = new Map();
3668
+ const hashInput = (relPath) => {
3669
+ if (!skipHashes.has(relPath)) skipHashes.set(relPath, hashFileAt(rootResolved, relPath) ?? 'absent');
3670
+ return skipHashes.get(relPath);
3671
+ };
3672
+ const captures = new Map(); // routeKey → capture record of a route rendered this run
3673
+ const carried = new Map(); // routeKey → previous entry revalidated and reused this run
3674
+ const state = { disabled: false, skipped: 0, reasons: {} };
3675
+ const disable = (reason) => {
3676
+ if (state.disabled) return;
3677
+ state.disabled = true;
3678
+ process.stdout.write(`prerender: incremental disabled for this run (${reason}) — rendering everything\n`);
3679
+ };
3680
+ // Why-rendered buckets for the run summary.
3681
+ const noteRender = (reason, entry) => {
3682
+ const r = String(reason || '');
3683
+ let bucket;
3684
+ if (r === 'non-cacheable') {
3685
+ const detail = String(entry?.reason || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim().replace(/[:\s]+$/, '');
3686
+ bucket = detail ? `non-cacheable: ${detail}` : 'non-cacheable';
3687
+ } else if (r.startsWith('no ledger entry')) bucket = 'new';
3688
+ else if (r.startsWith('input ')) bucket = 'changed inputs';
3689
+ else if (r.startsWith('resolution changed')) bucket = 'changed resolution';
3690
+ else if (r.startsWith('external changed')) bucket = 'changed externals';
3691
+ else if (r.startsWith('signature mismatch')) bucket = 'changed signature';
3692
+ else if (r.startsWith('previous output')) bucket = 'previous output missing/changed';
3693
+ else bucket = r.slice(0, 48) || 'unknown';
3694
+ state.reasons[bucket] = (state.reasons[bucket] || 0) + 1;
3695
+ };
3696
+
3697
+ return {
3698
+ state,
3699
+ captures,
3700
+ recordRoute(routeKey, record) { captures.set(routeKey, record); },
3701
+
3702
+ /** Skip pass: returns the subset of `paths` that must render; copies the
3703
+ * previous outputs of the rest into staging and carries their entries. */
3704
+ async partition(paths) {
3705
+ if (!ledger) {
3706
+ for (let i = 0; i < paths.length; i++) noteRender('no ledger entry');
3707
+ return paths;
3708
+ }
3709
+ const toRender = [];
3710
+ for (const seg of paths) {
3711
+ if (state.disabled) { noteRender('incremental disabled'); toRender.push(seg); continue; }
3712
+ const entry = ledger.routes[seg];
3713
+ let verdict;
3714
+ try {
3715
+ verdict = await evaluateRouteSkip({
3716
+ entry, hashInput, globalKey,
3717
+ resolveRequest: (pathname) => resolveStaticRequestFile(rootResolved, pathname),
3718
+ revalidateExternal: revalidate,
3719
+ outputHash: (rel) => hashFileAt(previousOutput, rel),
3720
+ });
3721
+ } catch (err) {
3722
+ disable(`skip evaluation failed: ${err?.message || err}`);
3723
+ noteRender('incremental disabled');
3724
+ toRender.push(seg);
3725
+ continue;
3726
+ }
3727
+ // A base whose locale variants substitute needs its cached raw DOM
3728
+ // snapshot back; absent or unreadable → render the base.
3729
+ let rawHtml = null;
3730
+ if (verdict.skip && !entry.promoted && deps.hasPendingVariants(seg)) {
3731
+ try { rawHtml = gunzipSync(readFileSync(rawHtmlCachePath(rootResolved, seg))).toString('utf8'); }
3732
+ catch { rawHtml = null; }
3733
+ if (!rawHtml) verdict = { skip: false, reason: 'raw snapshot missing' };
3734
+ }
3735
+ if (verdict.skip) {
3736
+ try {
3737
+ for (const rel of Object.keys(entry.outputs)) {
3738
+ const dest = join(stagingOutput, rel);
3739
+ mkdirSync(dirname(dest), { recursive: true });
3740
+ cpSync(join(previousOutput, rel), dest);
3741
+ }
3742
+ } catch (err) {
3743
+ verdict = { skip: false, reason: `copy failed: ${err?.message || err}` };
3744
+ }
3745
+ }
3746
+ if (!verdict.skip) { noteRender(verdict.reason, entry); toRender.push(seg); continue; }
3747
+ if (entry.promoted) deps.promoteVariants(seg);
3748
+ else if (rawHtml) deps.cacheRawHtml(seg, rawHtml);
3749
+ if (Array.isArray(entry.utility)) for (const b of entry.utility) deps.pushUtilityBlock(b);
3750
+ carried.set(seg, entry);
3751
+ state.skipped++;
3752
+ process.stdout.write(` [skip] ${seg === '' ? '/' : '/' + seg} (unchanged)\n`);
3753
+ }
3754
+ return toRender;
3755
+ },
3756
+
3757
+ /** Written atomically, only after the staged output replaced the previous
3758
+ * one — covers exactly this run's skipped + successfully rendered routes.
3759
+ * Inputs are re-hashed HERE, not from the skip pass's memo, so a mid-run
3760
+ * edit is recorded as it is now and a later revert can't skip-match. */
3761
+ async writeLedger(finalOutput) {
3762
+ if (state.disabled) return false;
3763
+ const writeHashes = new Map();
3764
+ const hashNow = (rel) => {
3765
+ if (!writeHashes.has(rel)) writeHashes.set(rel, hashFileAt(rootResolved, rel) ?? 'absent');
3766
+ return writeHashes.get(rel);
3767
+ };
3768
+ const routes = {};
3769
+ for (const [key, entry] of carried) routes[key] = entry;
3770
+ for (const [key, cap] of captures) {
3771
+ if (cap.nonCacheable) { routes[key] = { nonCacheable: true, reason: cap.reason }; continue; }
3772
+ const requested = {};
3773
+ const requestedPairs = [];
3774
+ for (const pathname of [...cap.requested.keys()].sort()) {
3775
+ const rel = cap.requested.get(pathname);
3776
+ requested[pathname] = rel;
3777
+ requestedPairs.push(`${pathname}=>${rel}`);
3778
+ }
3779
+ const inputs = {};
3780
+ const inputPairs = [];
3781
+ for (const rel of [...cap.files].sort()) {
3782
+ const h = hashNow(rel);
3783
+ inputs[rel] = h;
3784
+ inputPairs.push(`${rel}:${h}`);
3785
+ }
3786
+ const outputs = {};
3787
+ let missingOut = null;
3788
+ for (const rel of cap.outputs) {
3789
+ const h = hashFileAt(finalOutput, rel);
3790
+ if (!h) { missingOut = rel; break; }
3791
+ outputs[rel] = h;
3792
+ }
3793
+ if (missingOut) { routes[key] = { nonCacheable: true, reason: `output unreadable: ${missingOut}` }; continue; }
3794
+ const external = [...cap.externalPinned].sort();
3795
+ const externalValidated = {};
3796
+ const externalTokens = [...external];
3797
+ for (const url of [...cap.externalValidated.keys()].sort()) {
3798
+ const v = cap.externalValidated.get(url);
3799
+ externalValidated[url] = v;
3800
+ externalTokens.push(externalValidatorToken(url, v));
3801
+ }
3802
+ const entry = {
3803
+ sig: computeRouteSignature({ globalKey, inputPairs, requestedPairs, externalTokens }),
3804
+ inputs, requested, external, externalValidated, outputs,
3805
+ };
3806
+ if (Array.isArray(cap.utility) && cap.utility.length) entry.utility = cap.utility;
3807
+ if (deps.isPromotedBase(key)) {
3808
+ entry.promoted = true;
3809
+ } else if (deps.hasPendingVariants(key)) {
3810
+ const raw = deps.getRawHtml(key);
3811
+ if (!raw) { routes[key] = { nonCacheable: true, reason: 'raw snapshot unavailable' }; continue; }
3812
+ const p = rawHtmlCachePath(rootResolved, key);
3813
+ mkdirSync(dirname(p), { recursive: true });
3814
+ writeFileSync(p, gzipSync(Buffer.from(raw, 'utf8')));
3815
+ }
3816
+ routes[key] = entry;
3817
+ }
3818
+ const target = routesLedgerPath(rootResolved);
3819
+ mkdirSync(dirname(target), { recursive: true });
3820
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
3821
+ writeFileSync(tmp, JSON.stringify({
3822
+ schemaVersion: LEDGER_SCHEMA_VERSION,
3823
+ createdAt: new Date().toISOString(),
3824
+ routes,
3825
+ }) + '\n', 'utf8');
3826
+ renameSync(tmp, target);
3827
+ // GC: snapshots no route references, and orphaned ledger tmp files.
3828
+ try {
3829
+ const keep = new Set(Object.keys(routes).map((k) => basename(rawHtmlCachePath(rootResolved, k))));
3830
+ const snapDir = join(rootResolved, '.mnfst-cache', 'render-html');
3831
+ if (existsSync(snapDir)) {
3832
+ for (const f of readdirSync(snapDir)) {
3833
+ if (!keep.has(f)) { try { unlinkSync(join(snapDir, f)); } catch { /* in use elsewhere */ } }
3834
+ }
3835
+ }
3836
+ for (const f of readdirSync(join(rootResolved, '.mnfst-cache'))) {
3837
+ if (f.startsWith('routes.json.') && f.endsWith('.tmp')) {
3838
+ try { unlinkSync(join(rootResolved, '.mnfst-cache', f)); } catch { /* best-effort */ }
3839
+ }
3840
+ }
3841
+ } catch { /* GC is best-effort */ }
3842
+ return true;
3843
+ },
3844
+ };
3845
+ }
3846
+
3417
3847
  // --- Output lock (one render per output dir) ---------------------------------
3418
3848
 
3419
3849
  const LOCK_SUFFIX = '.mnfst-lock';
@@ -3972,7 +4402,11 @@ async function runPrerender(config) {
3972
4402
  * segment). When it does, every locale variant of this base is moved from
3973
4403
  * substitution to a real per-locale Puppeteer render.
3974
4404
  */
4405
+ // Bases whose locale variants were promoted this run — persisted in the
4406
+ // route ledger so a future skipped run re-promotes instead of substituting.
4407
+ const promotedBaseSegs = new Set();
3975
4408
  function promoteLocaleVariants(basePathSeg) {
4409
+ promotedBaseSegs.add(basePathSeg);
3976
4410
  const variants = variantsByBase.get(basePathSeg);
3977
4411
  if (!variants || !variants.length) return;
3978
4412
  process.stdout.write(
@@ -4002,9 +4436,58 @@ async function runPrerender(config) {
4002
4436
 
4003
4437
  // baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
4004
4438
  const baseHtmlCache = new Map();
4005
- const puppeteerTotal = puppeteerPaths.length;
4006
4439
 
4007
- process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution)...\n`);
4440
+ // --- Incremental skip pass (opt-in; see the incremental-rendering section) --
4441
+ // Skips reuse files from the PREVIOUS final output, which still exists while
4442
+ // this run builds into staging. Off → no capture, no ledger, no new work.
4443
+ let inc = null;
4444
+ if (config.incremental && !config.serve) {
4445
+ // Input capture maps request pathnames to disk via the built-in server's
4446
+ // resolution; an external --local server voids that mapping.
4447
+ process.stdout.write('prerender: incremental needs the built-in static server — ignoring it for this run\n');
4448
+ } else if (config.incremental) {
4449
+ let manifestContentHash = 'missing';
4450
+ try { manifestContentHash = sha256Full(readFileSync(join(config.root, 'manifest.json'))); } catch { /* missing */ }
4451
+ // Output-relevant resolved options (CLI included). pathList is in here
4452
+ // because per-page breadcrumb structured data depends on the full set.
4453
+ const optionsSalt = sha256Full(stableStringify({
4454
+ wait: config.wait,
4455
+ waitAfterIdle: config.waitAfterIdle,
4456
+ routerBase: config.routerBase,
4457
+ localeRouteExclude: config.localeRouteExclude,
4458
+ locales: config.locales ?? null,
4459
+ redirects: config.redirects,
4460
+ localeSubstitution: config.localeSubstitution,
4461
+ localeSubstitutionExclude: config.localeSubstitutionExclude,
4462
+ localeSubstitutionExcludePaths: config.localeSubstitutionExcludePaths,
4463
+ paths: config.paths,
4464
+ lazyIcons: config.lazyIcons,
4465
+ seo: config.seo,
4466
+ liveUrl: config.liveUrl,
4467
+ utilitiesBundle: bundleUtilities,
4468
+ tailwindBuilt,
4469
+ pathList: [...pathList].sort(),
4470
+ }));
4471
+ inc = createIncrementalRun({
4472
+ rootResolved,
4473
+ previousOutput: finalOutput,
4474
+ stagingOutput: outputResolved,
4475
+ globalKey: `${LEDGER_SCHEMA_VERSION}|${rendererPackageIdentity()}|${optionsSalt}|${manifestContentHash}`,
4476
+ deps: {
4477
+ hasPendingVariants: (seg) => variantsByBase.has(seg),
4478
+ promoteVariants: promoteLocaleVariants,
4479
+ isPromotedBase: (seg) => promotedBaseSegs.has(seg),
4480
+ cacheRawHtml: (seg, html) => baseHtmlCache.set(seg, html),
4481
+ getRawHtml: (seg) => baseHtmlCache.get(seg),
4482
+ pushUtilityBlock: (b) => utilityBlocks.push(b),
4483
+ },
4484
+ });
4485
+ }
4486
+ const puppeteerRenderPaths = inc ? await inc.partition(puppeteerPaths) : puppeteerPaths;
4487
+ let incRendered = puppeteerRenderPaths.length;
4488
+ const puppeteerTotal = puppeteerRenderPaths.length;
4489
+
4490
+ process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution${inc ? `, ${inc.state.skipped} skipped unchanged` : ''})...\n`);
4008
4491
 
4009
4492
  // Asset-wide fingerprint used as a cache-invalidator for OG snapshots:
4010
4493
  // changes to theme CSS, manifest config, or the root index.html mean every
@@ -4044,6 +4527,75 @@ async function runPrerender(config) {
4044
4527
  // live for the whole call.
4045
4528
  const page = await browser.newPage();
4046
4529
  if (attempt) attempt.cancel = () => page.close();
4530
+ // Incremental input capture: same-origin GETs map to disk files via the
4531
+ // static server's own resolution; anything un-fingerprintable marks the
4532
+ // route non-cacheable (it will always render).
4533
+ let capture = null;
4534
+ if (inc && !inc.state.disabled) {
4535
+ capture = {
4536
+ requested: new Map(), files: new Set(),
4537
+ externalPinned: new Set(), externalValidated: new Map(),
4538
+ awaiting: new Set(), pending: [], nonCacheable: false, reason: null,
4539
+ };
4540
+ const localOrigin = new URL(config.localUrl).origin;
4541
+ const flag = (reason) => {
4542
+ if (!capture.nonCacheable) { capture.nonCacheable = true; capture.reason = reason; }
4543
+ };
4544
+ page.on('request', (req) => {
4545
+ try {
4546
+ const u = new URL(req.url());
4547
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
4548
+ if (req.method() !== 'GET') { flag(`non-GET ${req.method()} ${u.href}`); return; }
4549
+ if (u.origin !== localOrigin) {
4550
+ // Pinned-version URL: the URL is its own identity. Anything else
4551
+ // must get a PROCESSED response (validator or content hash) by the
4552
+ // end of the route or it stays in `awaiting` → route renders.
4553
+ if (PINNED_VERSION_RE.test(u.pathname)) capture.externalPinned.add(u.href);
4554
+ else capture.awaiting.add(u.href);
4555
+ return;
4556
+ }
4557
+ const rel = resolveStaticRequestFile(rootResolved, u.pathname);
4558
+ if (rel) { capture.requested.set(u.pathname, rel); capture.files.add(rel); }
4559
+ else flag(`unresolvable request ${u.pathname}`);
4560
+ } catch { flag('unparseable request URL'); }
4561
+ });
4562
+ page.on('requestfailed', (req) => {
4563
+ try { capture.awaiting.delete(new URL(req.url()).href); } catch { /* flagged below */ }
4564
+ flag(`request failed: ${req.url()}`);
4565
+ });
4566
+ page.on('response', (res) => {
4567
+ try {
4568
+ const u = new URL(res.url());
4569
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
4570
+ const status = res.status();
4571
+ if (u.origin === localOrigin || PINNED_VERSION_RE.test(u.pathname)) {
4572
+ if (status >= 400) flag(`${status} ${u.href}`);
4573
+ return;
4574
+ }
4575
+ if (status < 200 || status >= 300) { flag(`external ${status} ${u.href}`); capture.awaiting.delete(u.href); return; }
4576
+ const headers = res.headers();
4577
+ const etag = headers['etag'];
4578
+ const lastModified = headers['last-modified'];
4579
+ if (etag || lastModified) {
4580
+ capture.externalValidated.set(u.href, etag ? { etag } : { lastModified });
4581
+ capture.awaiting.delete(u.href);
4582
+ return;
4583
+ }
4584
+ // No validator (e.g. cdn.manifestx.dev): content-address the bytes;
4585
+ // revalidation re-downloads and compares. Unreadable or oversized
4586
+ // body → render. `awaiting` clears only once the hash is recorded.
4587
+ capture.pending.push(
4588
+ res.buffer()
4589
+ .then((body) => {
4590
+ if (body.length > EXTERNAL_BODY_HASH_LIMIT) { flag(`external response too large to fingerprint: ${u.href}`); return; }
4591
+ capture.externalValidated.set(u.href, { contentHash: sha256Full(body) });
4592
+ capture.awaiting.delete(u.href);
4593
+ })
4594
+ .catch(() => flag(`external response unreadable: ${u.href}`))
4595
+ );
4596
+ } catch { flag('unparseable response URL'); }
4597
+ });
4598
+ }
4047
4599
  // Render at a typical desktop viewport so layouts dependent on viewport
4048
4600
  // width (responsive flex/grid, container queries, media queries) settle
4049
4601
  // into their desktop variant. Without this the headless default (often
@@ -5188,7 +5740,7 @@ async function runPrerender(config) {
5188
5740
  let tagged = false;
5189
5741
  const lookup = tpl._x_lookup;
5190
5742
  if (lookup) {
5191
- try { Object.values(lookup).forEach((el) => { if (el) { tag(el); tagged = true; } }); } catch { /* not iterable */ }
5743
+ try { (lookup instanceof Map ? [...lookup.values()] : Object.values(lookup)).forEach((el) => { if (el) { tag(el); tagged = true; } }); } catch { /* not iterable */ }
5192
5744
  }
5193
5745
  if (tagged) return;
5194
5746
  const first = tpl.content && tpl.content.firstElementChild;
@@ -5320,9 +5872,11 @@ async function runPrerender(config) {
5320
5872
  html = stripDataTailwindAttr(html);
5321
5873
  }
5322
5874
  html = debakeThemeClass(html);
5875
+ let routeUtilityBlocks = [];
5323
5876
  if (bundleUtilities) {
5324
5877
  const extracted = extractUtilityStyleBlocks(html);
5325
5878
  html = extracted.html;
5879
+ routeUtilityBlocks = extracted.blocks;
5326
5880
  for (const b of extracted.blocks) utilityBlocks.push(b);
5327
5881
  }
5328
5882
  if (tailwindBuilt) {
@@ -5379,6 +5933,24 @@ async function runPrerender(config) {
5379
5933
  if (attempt?.abandoned) return; // a retry owns the output now
5380
5934
  mkdirSync(outDir, { recursive: true });
5381
5935
  writeFileSync(outFile, html, 'utf8');
5936
+ if (capture) {
5937
+ await Promise.allSettled(capture.pending); // drain in-flight body hashes
5938
+ settleCaptureAwaiting(capture); // anything still pending → render
5939
+ const outputs = [relative(config.output, outFile).split(sep).join('/')];
5940
+ if (earlySnapshotUrl) outputs.push(String(earlySnapshotUrl).replace(/^\//, ''));
5941
+ // Node-side head/meta inputs the page never fetches.
5942
+ for (const rel of contentFilesForPrerender(manifest, currentLocale)) capture.files.add(rel);
5943
+ inc.recordRoute(pathSeg, {
5944
+ requested: capture.requested,
5945
+ files: capture.files,
5946
+ externalPinned: capture.externalPinned,
5947
+ externalValidated: capture.externalValidated,
5948
+ nonCacheable: capture.nonCacheable,
5949
+ reason: capture.reason,
5950
+ outputs,
5951
+ utility: routeUtilityBlocks,
5952
+ });
5953
+ }
5382
5954
  pushDebug({
5383
5955
  path: displayPath,
5384
5956
  stage: 'wrote',
@@ -5428,13 +6000,17 @@ async function runPrerender(config) {
5428
6000
  }
5429
6001
 
5430
6002
  try {
5431
- await renderPuppeteerPaths(puppeteerPaths);
6003
+ await renderPuppeteerPaths(puppeteerRenderPaths);
5432
6004
  // Any base page found to have per-locale runtime content during the pass
5433
6005
  // above gets its locale variants rendered here — real Puppeteer render
5434
6006
  // per locale instead of the (incorrect) text substitution.
5435
6007
  if (promotedPaths.length > 0) {
5436
- process.stdout.write(`prerender: rendering ${promotedPaths.length} promoted locale variant(s) via Puppeteer...\n`);
5437
- await renderPuppeteerPaths(promotedPaths);
6008
+ const promotedToRender = inc ? await inc.partition(promotedPaths) : promotedPaths;
6009
+ incRendered += promotedToRender.length;
6010
+ if (promotedToRender.length > 0) {
6011
+ process.stdout.write(`prerender: rendering ${promotedToRender.length} promoted locale variant(s) via Puppeteer...\n`);
6012
+ await renderPuppeteerPaths(promotedToRender);
6013
+ }
5438
6014
  }
5439
6015
  } finally {
5440
6016
  await closeBrowser(browser);
@@ -5482,6 +6058,11 @@ async function runPrerender(config) {
5482
6058
  throw new Error(`prerender failed for ${failedPaths.length}/${pathTotal} paths. Sample: ${sample}`);
5483
6059
  }
5484
6060
 
6061
+ if (inc) {
6062
+ const why = Object.entries(inc.state.reasons).sort((a, b) => b[1] - a[1]).map(([r, c]) => `${c} ${r}`);
6063
+ process.stdout.write(`prerender: skipped ${inc.state.skipped} unchanged, rendered ${incRendered}${why.length ? ` (${why.join(', ')})` : ''}\n`);
6064
+ }
6065
+
5485
6066
  if (config.debugPrerender) {
5486
6067
  const reportPath = join(outputResolved, 'prerender.debug.json');
5487
6068
  writeFileSync(reportPath, JSON.stringify({
@@ -5547,6 +6128,16 @@ async function runPrerender(config) {
5547
6128
  if (existsSync(finalOutput)) rmSync(finalOutput, { recursive: true });
5548
6129
  renameSync(stagingOutput, finalOutput);
5549
6130
  config.output = finalOutput;
6131
+
6132
+ // Ledger only after the swap — a run that failed anywhere above never
6133
+ // records anything, so the next run re-renders whatever is uncertain.
6134
+ if (inc) {
6135
+ try {
6136
+ await inc.writeLedger(finalOutput);
6137
+ } catch (err) {
6138
+ process.stderr.write(`prerender: incremental ledger not written (${err?.message || err})\n`);
6139
+ }
6140
+ }
5550
6141
  }
5551
6142
 
5552
6143
  // Auto-run main() when this file is the direct entry point (node manifest.render.mjs)
@@ -5585,4 +6176,15 @@ export {
5585
6176
  isLocaleSubstitutionExcludedPath,
5586
6177
  loadAllLocaleContentData,
5587
6178
  buildSubstitutionPairs,
6179
+ LEDGER_SCHEMA_VERSION,
6180
+ stableStringify,
6181
+ computeRouteSignature,
6182
+ evaluateRouteSkip,
6183
+ loadRouteLedger,
6184
+ resolveStaticRequestFile,
6185
+ createExternalRevalidator,
6186
+ contentFilesForPrerender,
6187
+ isSafeRelPath,
6188
+ settleCaptureAwaiting,
6189
+ EXTERNAL_BODY_HASH_LIMIT,
5588
6190
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.49",
3
+ "version": "0.5.51",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {