mnfst-render 0.5.40 → 0.5.42

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 +256 -25
  2. package/package.json +1 -1
@@ -208,6 +208,16 @@ function withDeadline(promise, ms) {
208
208
  ]);
209
209
  }
210
210
 
211
+ /**
212
+ * True for a Puppeteer failure message caused by a browser-recycle race
213
+ * (page/frame torn down mid-evaluate) rather than the page's own content —
214
+ * retrying with backoff wastes time on a race; the caller requeues these
215
+ * immediately instead. Exported for unit tests.
216
+ */
217
+ export function isTransientFrameError(message) {
218
+ return /detached frame|Execution context (is not available|was destroyed)/i.test(String(message || ''));
219
+ }
220
+
211
221
  /**
212
222
  * Coordinates N render workers around a shared browser that is swapped out
213
223
  * every `every` pages. Workers hold a slot (acquire/release) for exactly as
@@ -255,12 +265,20 @@ export function createRecycleGate({
255
265
  const processed = pages;
256
266
  try {
257
267
  if (active > 0) {
268
+ // Never swap the browser under a live page — a per-page caller
269
+ // timeout already bounds how long any page can hold a slot, so
270
+ // waiting here cannot deadlock. drainTimeoutMs is configured to
271
+ // exceed that per-page timeout (see recycleDrainTimeout default in
272
+ // resolveConfig), so hitting it means a page leaked past its own
273
+ // timeout without releasing — a bug, not a normal recycle path.
274
+ // Log it and keep waiting rather than recycle while it's live.
258
275
  const drained = await withDeadline(
259
276
  new Promise((resolve) => { zeroWaiters.push(resolve); }),
260
277
  drainTimeoutMs
261
278
  );
262
279
  if (!drained) {
263
- onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — recycling anyway`);
280
+ onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — waiting (a leaked page never released its slot)`);
281
+ await new Promise((resolve) => { zeroWaiters.push(resolve); });
264
282
  }
265
283
  }
266
284
  const swapped = await withDeadline(recycle(processed), recycleTimeoutMs);
@@ -297,6 +315,10 @@ function parseArgs() {
297
315
  if (args[i] === '--retries' && args[i + 1]) { out.retries = parseInt(args[++i], 10); continue; }
298
316
  if (args[i] === '--dry-run') { out.dryRun = true; continue; }
299
317
  if (args[i] === '--debug-prerender') { out.debugPrerender = true; continue; }
318
+ if (args[i] === '--locale-substitution') { out.localeSubstitution = true; continue; }
319
+ if (args[i] === '--no-locale-substitution') { out.localeSubstitution = false; continue; }
320
+ if (args[i] === '--locale-substitution-exclude' && args[i + 1]) { out.localeSubstitutionExclude = args[++i]; continue; }
321
+ if (args[i] === '--locale-substitution-exclude-paths' && args[i + 1]) { out.localeSubstitutionExcludePaths = args[++i]; continue; }
300
322
  }
301
323
  return out;
302
324
  }
@@ -340,6 +362,29 @@ function isLocaleRouteExcluded(seg, localeRouteExclude) {
340
362
  return false;
341
363
  }
342
364
 
365
+ /** Single glob pattern match against a locale-stripped route. Only a trailing
366
+ * `/*` (prefix wildcard) or bare `*` (match-all) is supported — mirrors the
367
+ * x-route wildcard-prefix convention used elsewhere in this file. */
368
+ function routeMatchesGlob(route, pattern) {
369
+ const p = String(pattern || '').replace(/^\/+|\/+$/g, '');
370
+ if (!p || p === '*') return !!p;
371
+ if (p.endsWith('/*')) {
372
+ const base = p.slice(0, -2);
373
+ return route === base || route.startsWith(base + '/');
374
+ }
375
+ return route === p;
376
+ }
377
+
378
+ /**
379
+ * True when `route` (locale-stripped, e.g. "articles/foo") matches any of
380
+ * `patterns` (manifest.prerender.localeSubstitutionExcludePaths) — such
381
+ * routes are Puppeteer-rendered for every locale instead of substituted.
382
+ */
383
+ function isLocaleSubstitutionExcludedPath(route, patterns) {
384
+ if (!route || !patterns || !patterns.length) return false;
385
+ return patterns.some((p) => routeMatchesGlob(route, p));
386
+ }
387
+
343
388
  /**
344
389
  * Expand discovered route segments into the full path list to render:
345
390
  * every locale-neutral route as-is, plus a `<locale>/<route>` variant for
@@ -420,8 +465,17 @@ function resolveConfig() {
420
465
  // closed errors. Users can override for small projects with --concurrency.
421
466
  concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? 2),
422
467
  retries: Math.max(0, cli.retries ?? pre.retries ?? 2),
423
- localeSubstitution: true,
424
- localeSubstitutionExclude: [],
468
+ // Locale substitution on/off + exclusions — read from CLI, then manifest.json
469
+ // prerender.*, like their sibling config values above (was hardcoded before).
470
+ localeSubstitution: cli.localeSubstitution ?? pre.localeSubstitution ?? true,
471
+ localeSubstitutionExclude: normalizeLocaleRouteExclude(
472
+ cli.localeSubstitutionExclude ?? pre.localeSubstitutionExclude
473
+ ),
474
+ /** Route globs (e.g. "articles/*"), matched against the locale-stripped route —
475
+ * matching routes are Puppeteer-rendered for every locale instead of substituted. */
476
+ localeSubstitutionExcludePaths: normalizeLocaleRouteExclude(
477
+ cli.localeSubstitutionExcludePaths ?? pre.localeSubstitutionExcludePaths
478
+ ),
425
479
  /** Explicit locale-neutral paths to render in addition to those discovered automatically.
426
480
  * Each entry is expanded to all locale variants (e.g. "legal/privacy" → "cs/legal/privacy", ...) */
427
481
  paths: Array.isArray(pre.paths)
@@ -1490,26 +1544,45 @@ function hasOtherOgMeta(html) {
1490
1544
  * Load the key→value content data for every locale from every CSV that has locale columns.
1491
1545
  * Returns Map<locale, { key: value }>.
1492
1546
  */
1493
- function loadAllLocaleContentData(manifest, rootDir, locales) {
1547
+ function loadAllLocaleContentData(manifest, rootDir, locales, defaultLocale) {
1494
1548
  const data = manifest?.data;
1495
1549
  if (!data || typeof data !== 'object') return new Map();
1550
+ const defaultLoc = defaultLocale || locales[0];
1496
1551
 
1497
1552
  // Lazy-load js-yaml for parsing per-locale YAML files
1498
1553
  let jsYaml = null;
1499
1554
  try { jsYaml = require('js-yaml'); } catch { /* yaml not available; YAML locale files will be skipped */ }
1500
1555
 
1501
- // Deep-merge source into target (for combining multiple data sources per locale)
1556
+ // A root-level YAML/JSON LIST (e.g. articles.<locale>.yaml) has no natural
1557
+ // key to merge on — key each item by the first present of slug/id/key/path,
1558
+ // falling back to its index, so same-item pairing works across locales.
1559
+ function arrayItemKey(item, idx) {
1560
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
1561
+ const k = item.slug ?? item.id ?? item.key ?? item.path;
1562
+ if (k != null) return String(k);
1563
+ }
1564
+ return String(idx);
1565
+ }
1566
+ function arrayToKeyedObject(arr) {
1567
+ const out = {};
1568
+ arr.forEach((item, idx) => { out[arrayItemKey(item, idx)] = item; });
1569
+ return out;
1570
+ }
1571
+
1572
+ // Deep-merge source into target (for combining multiple data sources per locale).
1573
+ // A root-level array source is keyed first (see above) then merged as an object.
1502
1574
  function deepMerge(target, source) {
1503
- if (!source || typeof source !== 'object' || Array.isArray(source)) return;
1504
- for (const key of Object.keys(source)) {
1505
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
1575
+ if (!source || typeof source !== 'object') return;
1576
+ const src = Array.isArray(source) ? arrayToKeyedObject(source) : source;
1577
+ for (const key of Object.keys(src)) {
1578
+ if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
1506
1579
  target[key] = (target[key] && typeof target[key] === 'object') ? target[key] : {};
1507
- deepMerge(target[key], source[key]);
1580
+ deepMerge(target[key], src[key]);
1508
1581
  } else {
1509
1582
  // Don't overwrite an existing nested object with a primitive — that creates
1510
1583
  // type asymmetry across locales and causes '[object Object]' in substitution pairs
1511
1584
  if (target[key] && typeof target[key] === 'object') continue;
1512
- target[key] = source[key];
1585
+ target[key] = src[key];
1513
1586
  }
1514
1587
  }
1515
1588
  }
@@ -1517,6 +1590,20 @@ function loadAllLocaleContentData(manifest, rootDir, locales) {
1517
1590
  const result = new Map();
1518
1591
  for (const locale of locales) result.set(locale, {});
1519
1592
 
1593
+ // A configured per-locale source that resolves to 0 substitution pairs against
1594
+ // the default locale is silent data loss (e.g. the array-rooted bug above, before
1595
+ // the fix) — warn once per (source, locale) so it surfaces instead of shipping quietly.
1596
+ const warnedZeroPairs = new Set();
1597
+ function warnIfZeroPairs(name, locale, defaultData, localeData) {
1598
+ if (locale === defaultLoc || !defaultData || !localeData) return;
1599
+ const warnKey = `${name}:${locale}`;
1600
+ if (warnedZeroPairs.has(warnKey)) return;
1601
+ if (buildSubstitutionPairs(defaultData, localeData).length === 0) {
1602
+ warnedZeroPairs.add(warnKey);
1603
+ console.warn(`prerender: warning — locale source ${name} (${locale}) produced 0 substitution pairs`);
1604
+ }
1605
+ }
1606
+
1520
1607
  // Read just the header row of a CSV to check which locale columns it contains.
1521
1608
  function csvLocaleColumns(csvPath) {
1522
1609
  if (!existsSync(csvPath)) return new Set();
@@ -1526,17 +1613,20 @@ function loadAllLocaleContentData(manifest, rootDir, locales) {
1526
1613
  } catch { return new Set(); }
1527
1614
  }
1528
1615
 
1529
- for (const [, value] of Object.entries(data)) {
1616
+ for (const [name, value] of Object.entries(data)) {
1530
1617
  if (typeof value === 'string') {
1531
1618
  // Single CSV with locale columns (all locales in one file)
1532
1619
  if (value.endsWith('.csv')) {
1533
1620
  const csvPath = join(rootDir, value.startsWith('/') ? value.slice(1) : value);
1534
1621
  const cols = csvLocaleColumns(csvPath);
1622
+ const defaultData = cols.has(defaultLoc) ? parseCsvToKeyValue(csvPath, defaultLoc) : null;
1535
1623
  for (const locale of locales) {
1536
1624
  // Only include locales the CSV actually declares; falling back to the English
1537
1625
  // column for a missing locale silently poisons substitution pairs with English values.
1538
1626
  if (!cols.has(locale)) continue;
1539
- deepMerge(result.get(locale), parseCsvToKeyValue(csvPath, locale));
1627
+ const localeData = parseCsvToKeyValue(csvPath, locale);
1628
+ deepMerge(result.get(locale), localeData);
1629
+ warnIfZeroPairs(name, locale, defaultData, localeData);
1540
1630
  }
1541
1631
  }
1542
1632
  } else if (value && typeof value === 'object' && !Array.isArray(value)) {
@@ -1547,13 +1637,18 @@ function loadAllLocaleContentData(manifest, rootDir, locales) {
1547
1637
  if (typeof ref !== 'string' || !ref.endsWith('.csv')) continue;
1548
1638
  const csvPath = join(rootDir, ref.startsWith('/') ? ref.slice(1) : ref);
1549
1639
  const cols = csvLocaleColumns(csvPath);
1640
+ const defaultData = cols.has(defaultLoc) ? parseCsvToKeyValue(csvPath, defaultLoc) : null;
1550
1641
  for (const locale of locales) {
1551
1642
  if (!cols.has(locale)) continue;
1552
- deepMerge(result.get(locale), parseCsvToKeyValue(csvPath, locale));
1643
+ const localeData = parseCsvToKeyValue(csvPath, locale);
1644
+ deepMerge(result.get(locale), localeData);
1645
+ warnIfZeroPairs(name, locale, defaultData, localeData);
1553
1646
  }
1554
1647
  }
1555
1648
  } else {
1556
1649
  // Per-locale files: { "en": "/data/content.en.yaml", "fr": "/data/content.fr.yaml", ... }
1650
+ // (each file's content may be an object OR a root-level array — see deepMerge).
1651
+ const loadedByLocale = new Map();
1557
1652
  for (const [localeKey, filePath] of Object.entries(value)) {
1558
1653
  if (!locales.includes(localeKey) || typeof filePath !== 'string') continue;
1559
1654
  const fullPath = join(rootDir, filePath.startsWith('/') ? filePath.slice(1) : filePath);
@@ -1570,9 +1665,14 @@ function loadAllLocaleContentData(manifest, rootDir, locales) {
1570
1665
  }
1571
1666
  } catch { /* ignore parse errors for individual locale files */ }
1572
1667
  if (localeData && typeof localeData === 'object') {
1668
+ loadedByLocale.set(localeKey, Array.isArray(localeData) ? arrayToKeyedObject(localeData) : localeData);
1573
1669
  deepMerge(result.get(localeKey), localeData);
1574
1670
  }
1575
1671
  }
1672
+ const defaultData = loadedByLocale.get(defaultLoc) || null;
1673
+ for (const [localeKey, localeData] of loadedByLocale) {
1674
+ warnIfZeroPairs(name, localeKey, defaultData, localeData);
1675
+ }
1576
1676
  }
1577
1677
  }
1578
1678
  }
@@ -3391,9 +3491,13 @@ async function runPrerender(config) {
3391
3491
  // over — and a page that hits the ceiling forces a browser recycle, since
3392
3492
  // a wedged page.evaluate() may have poisoned the shared browser process.
3393
3493
  // pageTimeout is configurable via manifest.prerender.pageTimeout; 0 disables.
3394
- const drainTimeoutMs = Math.max(0, pre.recycleDrainTimeout ?? 60000);
3395
- const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
3396
3494
  const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
3495
+ // The recycle gate must never swap the browser out from under a live page,
3496
+ // so its drain wait has to outlast the per-page timeout above (every page
3497
+ // is guaranteed to release its slot by then). Configurable via
3498
+ // manifest.prerender.recycleDrainTimeout, floored at pageTimeoutMs + 30s.
3499
+ const drainTimeoutMs = Math.max(pageTimeoutMs + 30000, pre.recycleDrainTimeout ?? 60000);
3500
+ const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
3397
3501
 
3398
3502
  // Close the browser without hanging: SIGKILL the process if close stalls.
3399
3503
  async function closeBrowser(target) {
@@ -3455,6 +3559,7 @@ async function runPrerender(config) {
3455
3559
  // use a shared structure with CSV text overlay; locale-prefixed paths carry per-locale content.
3456
3560
 
3457
3561
  // Pass 1: collect all locale-neutral path segments (no locale prefix in the first segment).
3562
+ const localeSet = new Set(locales);
3458
3563
  const localeNeutralPathSet = new Set();
3459
3564
  for (const seg of pathList) {
3460
3565
  if (!seg || seg === NOT_FOUND_PATH) continue;
@@ -3473,6 +3578,13 @@ async function runPrerender(config) {
3473
3578
  continue;
3474
3579
  }
3475
3580
  const basePathSeg = seg.slice(fp.length + 1) || '';
3581
+ if (isLocaleSubstitutionExcludedPath(basePathSeg, config.localeSubstitutionExcludePaths)) {
3582
+ // Route explicitly configured (prerender.localeSubstitutionExcludePaths) to
3583
+ // always be Puppeteer-rendered per locale — e.g. it has other per-locale
3584
+ // runtime behaviour the client wants verified directly, not substituted.
3585
+ puppeteerPaths.push(seg);
3586
+ continue;
3587
+ }
3476
3588
  if (localeNeutralPathSet.has(basePathSeg)) {
3477
3589
  // Locale-neutral base exists and will be Puppeteer-rendered → safe to substitute.
3478
3590
  localeVariantPaths.push({ pathSeg: seg, basePathSeg, targetLocale: fp });
@@ -3482,8 +3594,46 @@ async function runPrerender(config) {
3482
3594
  }
3483
3595
  }
3484
3596
 
3597
+ // variantsByBase: basePathSeg → its pending substitution-variant entries.
3598
+ // Populated above; consumed by promoteLocaleVariants() during Phase 1 when a
3599
+ // base page turns out to have per-locale runtime content (see below).
3600
+ const variantsByBase = new Map();
3601
+ for (const v of localeVariantPaths) {
3602
+ if (!variantsByBase.has(v.basePathSeg)) variantsByBase.set(v.basePathSeg, []);
3603
+ variantsByBase.get(v.basePathSeg).push(v);
3604
+ }
3605
+ // Collected during Phase 1, rendered in a follow-up Puppeteer pass after it.
3606
+ const promotedPaths = [];
3607
+ /**
3608
+ * Defect: locale substitution ships default-locale bodies for paths whose
3609
+ * content is fetched per-locale at runtime (e.g. `x-markdown` resolving
3610
+ * `/assets/articles/<locale>/slug.md`). Substitution only rewrites text
3611
+ * matched by configured locale data — it can't touch content injected by a
3612
+ * runtime fetch, so the baked (default-locale) body shipped for every
3613
+ * locale. Detection: instrument `window.fetch` before app scripts run (see
3614
+ * evaluateOnNewDocument below) and, after the base page renders, check
3615
+ * whether any fetched URL contains the CURRENT locale as a path segment —
3616
+ * strong evidence the URL is locale-parameterized (a locale-neutral URL
3617
+ * would not coincidentally embed the active locale code as its own
3618
+ * segment). When it does, every locale variant of this base is moved from
3619
+ * substitution to a real per-locale Puppeteer render.
3620
+ */
3621
+ function promoteLocaleVariants(basePathSeg) {
3622
+ const variants = variantsByBase.get(basePathSeg);
3623
+ if (!variants || !variants.length) return;
3624
+ process.stdout.write(
3625
+ `prerender: locale-dependent runtime content detected for "${basePathSeg || '/'}" — rendering ${variants.length} locale variant(s) via Puppeteer instead of substitution\n`
3626
+ );
3627
+ for (const v of variants) {
3628
+ promotedPaths.push(v.pathSeg);
3629
+ const idx = localeVariantPaths.indexOf(v);
3630
+ if (idx !== -1) localeVariantPaths.splice(idx, 1);
3631
+ }
3632
+ variantsByBase.delete(basePathSeg);
3633
+ }
3634
+
3485
3635
  // Preload locale data for text substitution (all CSV sources with locale columns)
3486
- const allLocaleData = loadAllLocaleContentData(manifest, config.root, locales);
3636
+ const allLocaleData = loadAllLocaleContentData(manifest, config.root, locales, defaultLocale);
3487
3637
  const substitutionMaps = new Map(); // locale → [[from, to], ...]
3488
3638
  for (const locale of locales) {
3489
3639
  if (locale === defaultLocale) {
@@ -3520,7 +3670,7 @@ async function runPrerender(config) {
3520
3670
  debugRows.push(row);
3521
3671
  }
3522
3672
 
3523
- async function processPath(pathSeg, pathIndex, { onRawHtml, attempt } = {}) {
3673
+ async function processPath(pathSeg, pathIndex, { onRawHtml, attempt, onLocaleDependentContent } = {}) {
3524
3674
  const is404 = pathSeg === NOT_FOUND_PATH;
3525
3675
  const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
3526
3676
  const displayPath = pathSeg === '' ? '/' : pathname;
@@ -3587,6 +3737,31 @@ async function runPrerender(config) {
3587
3737
  }
3588
3738
  }, currentLocale);
3589
3739
 
3740
+ // Record fetch() URLs for CONTENT files (e.g. x-markdown resolving
3741
+ // `/assets/articles/<locale>/slug.md`) so the caller can detect, after
3742
+ // this page renders, whether its content was fetched from a
3743
+ // locale-parameterized path — see promoteLocaleVariants() above.
3744
+ // Deliberately excludes structured-data extensions (.json/.yaml/.yml/.csv)
3745
+ // — those are the data/localization plugins' own per-locale fetches,
3746
+ // already handled correctly by the Node-side substitution pipeline
3747
+ // (loadAllLocaleContentData); counting them here false-positives on
3748
+ // every page that merely has locale-keyed data configured, regardless
3749
+ // of whether IT actually renders anything locale-dependent.
3750
+ await page.evaluateOnNewDocument(() => {
3751
+ window.__mnfstFetchedUrls = [];
3752
+ const CONTENT_URL_RE = /\.(md|markdown|html?|txt)([?#]|$)/i;
3753
+ const origFetch = window.fetch;
3754
+ if (typeof origFetch === 'function') {
3755
+ window.fetch = function (input, init) {
3756
+ try {
3757
+ const url = typeof input === 'string' ? input : (input && input.url);
3758
+ if (url && CONTENT_URL_RE.test(String(url))) window.__mnfstFetchedUrls.push(String(url));
3759
+ } catch { /* no-op */ }
3760
+ return origFetch.call(this, input, init);
3761
+ };
3762
+ }
3763
+ });
3764
+
3590
3765
  // Deterministic source-attribute capture via MutationObserver with
3591
3766
  // `attributeOldValue`. This runs before ANY page script and records the
3592
3767
  // first (pre-mutation) value of every attribute that Alpine or a Manifest
@@ -3983,6 +4158,35 @@ async function runPrerender(config) {
3983
4158
  });
3984
4159
  });
3985
4160
 
4161
+ // Classification for the locale-substitution defect (see promoteLocaleVariants
4162
+ // above): did this page fetch a URL that embeds the locale it was just
4163
+ // rendered at? If so its content is locale-parameterized at runtime and
4164
+ // substitution would ship this (possibly default-locale) body to every
4165
+ // locale — flag it so the caller Puppeteer-renders each locale variant instead.
4166
+ if (config.localeSubstitution && onLocaleDependentContent && currentLocale) {
4167
+ const fetchedUrls = await page
4168
+ .evaluate(() => {
4169
+ const seen = (window.__mnfstFetchedUrls || []).slice();
4170
+ // Deterministic complement to the fetch hook: content directives
4171
+ // stamp their resolved source before fetching, so a fetch that
4172
+ // races page settle is still visible here.
4173
+ for (const el of document.querySelectorAll('[data-mnfst-md-src]')) {
4174
+ const v = el.getAttribute('data-mnfst-md-src');
4175
+ if (v) seen.push(v);
4176
+ }
4177
+ return seen;
4178
+ })
4179
+ .catch(() => []);
4180
+ const localeToken = currentLocale.toLowerCase();
4181
+ const localeDependent = fetchedUrls.some((u) => {
4182
+ try {
4183
+ const p = new URL(u, config.localUrl).pathname;
4184
+ return p.split('/').filter(Boolean).some((s) => s.toLowerCase() === localeToken);
4185
+ } catch { return false; }
4186
+ });
4187
+ if (localeDependent) onLocaleDependentContent(pathSeg || '');
4188
+ }
4189
+
3986
4190
  // Emit the hydration contract: walk the DOM, identify every hydrate
3987
4191
  // target (explicit `data-hydrate`, interactive Manifest directives,
3988
4192
  // diff-semantic bindings, runtime-magic-driven bindings), tag each with
@@ -4820,14 +5024,20 @@ async function runPrerender(config) {
4820
5024
  // Browser recycling: after every `browserRecycleEvery` successful pages the
4821
5025
  // gate pauses new work, drains the in-flight pages, swaps the browser and
4822
5026
  // resumes. Workers hold a gate slot only while they touch the browser.
4823
- try {
5027
+ //
5028
+ // A failure whose message matches a transient Puppeteer/browser-recycle race
5029
+ // (detached frame, destroyed execution context) is requeued immediately —
5030
+ // no backoff — since waiting doesn't help a race and each attempt already
5031
+ // gets its own fresh `pageTimeoutMs` budget.
5032
+ async function renderPuppeteerPaths(paths) {
4824
5033
  let index = 0;
5034
+ const total = paths.length;
4825
5035
 
4826
5036
  async function worker() {
4827
5037
  while (true) {
4828
5038
  const i = index++;
4829
- if (i >= puppeteerPaths.length) return;
4830
- const pathSeg = puppeteerPaths[i];
5039
+ if (i >= paths.length) return;
5040
+ const pathSeg = paths[i];
4831
5041
  const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
4832
5042
  let attempt = 0;
4833
5043
  while (true) {
@@ -4843,6 +5053,7 @@ async function runPrerender(config) {
4843
5053
  onRawHtml: (seg, html) => {
4844
5054
  if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
4845
5055
  },
5056
+ onLocaleDependentContent: promoteLocaleVariants,
4846
5057
  }),
4847
5058
  pageTimeoutMs
4848
5059
  );
@@ -4879,16 +5090,21 @@ async function runPrerender(config) {
4879
5090
  recycleGate.requestRecycle();
4880
5091
  break;
4881
5092
  }
5093
+ const immediateRequeue = isTransientFrameError(attemptToken.failure.message);
4882
5094
  // Halfway through retries with no success → preemptively recycle the
4883
5095
  // browser before the next attempt. This unblocks cascading frame
4884
- // failures where the browser process needs a fresh start.
4885
- if (attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
5096
+ // failures where the browser process needs a fresh start. Skipped
5097
+ // for a transient frame error — that's likely already a recycle
5098
+ // race, not a signal to force another one.
5099
+ if (!immediateRequeue && attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
4886
5100
  recycleGate.requestRecycle();
4887
5101
  await recycleGate.maybeRecycle();
4888
5102
  }
4889
5103
  attempt++;
4890
- process.stderr.write(`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})\n`);
4891
- await new Promise((r) => setTimeout(r, 500 * attempt));
5104
+ process.stderr.write(
5105
+ `prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})${immediateRequeue ? ' — immediate requeue' : ''}\n`
5106
+ );
5107
+ if (!immediateRequeue) await new Promise((r) => setTimeout(r, 500 * attempt));
4892
5108
  }
4893
5109
  // Attempt recycle after each completed path (only one worker performs
4894
5110
  // it; the rest block in acquire()).
@@ -4896,8 +5112,19 @@ async function runPrerender(config) {
4896
5112
  }
4897
5113
  }
4898
5114
  await Promise.all(
4899
- Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
5115
+ Array.from({ length: Math.min(concurrency, total || 1) }, () => worker())
4900
5116
  );
5117
+ }
5118
+
5119
+ try {
5120
+ await renderPuppeteerPaths(puppeteerPaths);
5121
+ // Any base page found to have per-locale runtime content during the pass
5122
+ // above gets its locale variants rendered here — real Puppeteer render
5123
+ // per locale instead of the (incorrect) text substitution.
5124
+ if (promotedPaths.length > 0) {
5125
+ process.stdout.write(`prerender: rendering ${promotedPaths.length} promoted locale variant(s) via Puppeteer...\n`);
5126
+ await renderPuppeteerPaths(promotedPaths);
5127
+ }
4901
5128
  } finally {
4902
5129
  await closeBrowser(browser);
4903
5130
  }
@@ -5035,4 +5262,8 @@ export {
5035
5262
  isLocaleRouteExcluded,
5036
5263
  expandLocaleRoutePaths,
5037
5264
  prefixLocaleInternalLinks,
5265
+ routeMatchesGlob,
5266
+ isLocaleSubstitutionExcludedPath,
5267
+ loadAllLocaleContentData,
5268
+ buildSubstitutionPairs,
5038
5269
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.40",
3
+ "version": "0.5.42",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {