mnfst-render 0.5.41 → 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.
- package/manifest.render.mjs +255 -25
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
//
|
|
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'
|
|
1504
|
-
|
|
1505
|
-
|
|
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],
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -3474,6 +3578,13 @@ async function runPrerender(config) {
|
|
|
3474
3578
|
continue;
|
|
3475
3579
|
}
|
|
3476
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
|
+
}
|
|
3477
3588
|
if (localeNeutralPathSet.has(basePathSeg)) {
|
|
3478
3589
|
// Locale-neutral base exists and will be Puppeteer-rendered → safe to substitute.
|
|
3479
3590
|
localeVariantPaths.push({ pathSeg: seg, basePathSeg, targetLocale: fp });
|
|
@@ -3483,8 +3594,46 @@ async function runPrerender(config) {
|
|
|
3483
3594
|
}
|
|
3484
3595
|
}
|
|
3485
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
|
+
|
|
3486
3635
|
// Preload locale data for text substitution (all CSV sources with locale columns)
|
|
3487
|
-
const allLocaleData = loadAllLocaleContentData(manifest, config.root, locales);
|
|
3636
|
+
const allLocaleData = loadAllLocaleContentData(manifest, config.root, locales, defaultLocale);
|
|
3488
3637
|
const substitutionMaps = new Map(); // locale → [[from, to], ...]
|
|
3489
3638
|
for (const locale of locales) {
|
|
3490
3639
|
if (locale === defaultLocale) {
|
|
@@ -3521,7 +3670,7 @@ async function runPrerender(config) {
|
|
|
3521
3670
|
debugRows.push(row);
|
|
3522
3671
|
}
|
|
3523
3672
|
|
|
3524
|
-
async function processPath(pathSeg, pathIndex, { onRawHtml, attempt } = {}) {
|
|
3673
|
+
async function processPath(pathSeg, pathIndex, { onRawHtml, attempt, onLocaleDependentContent } = {}) {
|
|
3525
3674
|
const is404 = pathSeg === NOT_FOUND_PATH;
|
|
3526
3675
|
const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
|
|
3527
3676
|
const displayPath = pathSeg === '' ? '/' : pathname;
|
|
@@ -3588,6 +3737,31 @@ async function runPrerender(config) {
|
|
|
3588
3737
|
}
|
|
3589
3738
|
}, currentLocale);
|
|
3590
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
|
+
|
|
3591
3765
|
// Deterministic source-attribute capture via MutationObserver with
|
|
3592
3766
|
// `attributeOldValue`. This runs before ANY page script and records the
|
|
3593
3767
|
// first (pre-mutation) value of every attribute that Alpine or a Manifest
|
|
@@ -3984,6 +4158,35 @@ async function runPrerender(config) {
|
|
|
3984
4158
|
});
|
|
3985
4159
|
});
|
|
3986
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
|
+
|
|
3987
4190
|
// Emit the hydration contract: walk the DOM, identify every hydrate
|
|
3988
4191
|
// target (explicit `data-hydrate`, interactive Manifest directives,
|
|
3989
4192
|
// diff-semantic bindings, runtime-magic-driven bindings), tag each with
|
|
@@ -4821,14 +5024,20 @@ async function runPrerender(config) {
|
|
|
4821
5024
|
// Browser recycling: after every `browserRecycleEvery` successful pages the
|
|
4822
5025
|
// gate pauses new work, drains the in-flight pages, swaps the browser and
|
|
4823
5026
|
// resumes. Workers hold a gate slot only while they touch the browser.
|
|
4824
|
-
|
|
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) {
|
|
4825
5033
|
let index = 0;
|
|
5034
|
+
const total = paths.length;
|
|
4826
5035
|
|
|
4827
5036
|
async function worker() {
|
|
4828
5037
|
while (true) {
|
|
4829
5038
|
const i = index++;
|
|
4830
|
-
if (i >=
|
|
4831
|
-
const pathSeg =
|
|
5039
|
+
if (i >= paths.length) return;
|
|
5040
|
+
const pathSeg = paths[i];
|
|
4832
5041
|
const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
|
|
4833
5042
|
let attempt = 0;
|
|
4834
5043
|
while (true) {
|
|
@@ -4844,6 +5053,7 @@ async function runPrerender(config) {
|
|
|
4844
5053
|
onRawHtml: (seg, html) => {
|
|
4845
5054
|
if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
|
|
4846
5055
|
},
|
|
5056
|
+
onLocaleDependentContent: promoteLocaleVariants,
|
|
4847
5057
|
}),
|
|
4848
5058
|
pageTimeoutMs
|
|
4849
5059
|
);
|
|
@@ -4880,16 +5090,21 @@ async function runPrerender(config) {
|
|
|
4880
5090
|
recycleGate.requestRecycle();
|
|
4881
5091
|
break;
|
|
4882
5092
|
}
|
|
5093
|
+
const immediateRequeue = isTransientFrameError(attemptToken.failure.message);
|
|
4883
5094
|
// Halfway through retries with no success → preemptively recycle the
|
|
4884
5095
|
// browser before the next attempt. This unblocks cascading frame
|
|
4885
|
-
// failures where the browser process needs a fresh start.
|
|
4886
|
-
|
|
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) {
|
|
4887
5100
|
recycleGate.requestRecycle();
|
|
4888
5101
|
await recycleGate.maybeRecycle();
|
|
4889
5102
|
}
|
|
4890
5103
|
attempt++;
|
|
4891
|
-
process.stderr.write(
|
|
4892
|
-
|
|
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));
|
|
4893
5108
|
}
|
|
4894
5109
|
// Attempt recycle after each completed path (only one worker performs
|
|
4895
5110
|
// it; the rest block in acquire()).
|
|
@@ -4897,8 +5112,19 @@ async function runPrerender(config) {
|
|
|
4897
5112
|
}
|
|
4898
5113
|
}
|
|
4899
5114
|
await Promise.all(
|
|
4900
|
-
Array.from({ length: Math.min(concurrency,
|
|
5115
|
+
Array.from({ length: Math.min(concurrency, total || 1) }, () => worker())
|
|
4901
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
|
+
}
|
|
4902
5128
|
} finally {
|
|
4903
5129
|
await closeBrowser(browser);
|
|
4904
5130
|
}
|
|
@@ -5036,4 +5262,8 @@ export {
|
|
|
5036
5262
|
isLocaleRouteExcluded,
|
|
5037
5263
|
expandLocaleRoutePaths,
|
|
5038
5264
|
prefixLocaleInternalLinks,
|
|
5265
|
+
routeMatchesGlob,
|
|
5266
|
+
isLocaleSubstitutionExcludedPath,
|
|
5267
|
+
loadAllLocaleContentData,
|
|
5268
|
+
buildSubstitutionPairs,
|
|
5039
5269
|
};
|