mnfst-render 0.5.39 → 0.5.40

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 +82 -35
  2. package/package.json +1 -1
@@ -317,12 +317,69 @@ function loadConfig(rootDir) {
317
317
  }
318
318
 
319
319
  function normalizeLocaleRouteExclude(val) {
320
+ const clean = (s) => String(s).trim().replace(/^\/+|\/+$/g, '');
320
321
  if (val == null) return [];
321
- if (Array.isArray(val)) return val.map((s) => String(s).trim()).filter(Boolean);
322
- if (typeof val === 'string') return val.split(',').map((s) => s.trim()).filter(Boolean);
322
+ if (Array.isArray(val)) return val.map(clean).filter(Boolean);
323
+ if (typeof val === 'string') return val.split(',').map(clean).filter(Boolean);
323
324
  return [];
324
325
  }
325
326
 
327
+ /**
328
+ * Shared predicate: true when route segment `seg` (no leading slash) falls
329
+ * under one of the excluded prefixes — prefix-matched on whole segments, so
330
+ * "docs" excludes "docs" itself and "docs/group/article" but not "docsite".
331
+ * Used by locale route expansion, link prefixing, and (mirrored in
332
+ * mnfst-publish) the publisher's expected-file computation.
333
+ */
334
+ function isLocaleRouteExcluded(seg, localeRouteExclude) {
335
+ if (!seg || !localeRouteExclude || !localeRouteExclude.length) return false;
336
+ const clean = String(seg).replace(/^\/+|\/+$/g, '');
337
+ for (const prefix of localeRouteExclude) {
338
+ if (clean === prefix || clean.startsWith(prefix + '/')) return true;
339
+ }
340
+ return false;
341
+ }
342
+
343
+ /**
344
+ * Expand discovered route segments into the full path list to render:
345
+ * every locale-neutral route as-is, plus a `<locale>/<route>` variant for
346
+ * every non-default locale (and for the default locale itself, under its
347
+ * own slug, when there's more than one locale) — except routes under an
348
+ * excluded prefix (localeRouteExclude), which are never locale-prefixed.
349
+ * Pure/side-effect-free so it can be unit tested without Puppeteer.
350
+ */
351
+ function expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeRouteExclude }) {
352
+ const localeList = locales || [];
353
+ const localeSet = new Set(localeList.map((l) => String(l).toLowerCase()));
354
+ const localeNeutralSegments = routeSegments.filter((seg) => {
355
+ if (!seg) return true;
356
+ const first = seg.split('/')[0].toLowerCase();
357
+ return !localeSet.has(first);
358
+ });
359
+ const paths = new Set();
360
+ paths.add('');
361
+ for (const seg of routeSegments) paths.add(seg);
362
+ for (const locale of localeList.slice(1)) {
363
+ paths.add(locale);
364
+ for (const seg of localeNeutralSegments) {
365
+ if (!seg) continue;
366
+ if (isLocaleRouteExcluded(seg, localeRouteExclude)) continue;
367
+ paths.add(`${locale}/${seg}`);
368
+ }
369
+ }
370
+ // Default locale also under its slug (e.g. /en/, /en/page-1) so linking is
371
+ // symmetric with other locales; canonical points to root. Skip this when
372
+ // there's only one locale — the duplicates serve no purpose and bloat the
373
+ // output (every page would be written twice: at root AND under /en/).
374
+ if (defaultLocale && localeList.length > 1) {
375
+ paths.add(defaultLocale);
376
+ for (const seg of localeNeutralSegments) {
377
+ if (seg !== '' && !isLocaleRouteExcluded(seg, localeRouteExclude)) paths.add(`${defaultLocale}/${seg}`);
378
+ }
379
+ }
380
+ return paths;
381
+ }
382
+
326
383
  function resolveConfig() {
327
384
  const cli = parseArgs();
328
385
  const cwd = process.cwd();
@@ -1567,7 +1624,6 @@ function buildSubstitutionPairs(defaultLocaleData, targetLocaleData) {
1567
1624
  function prefixLocaleInternalLinks(html, locale, locales, localeRouteExclude) {
1568
1625
  if (!locale || !locales || !locales.length) return html;
1569
1626
  const localeSet = new Set(locales);
1570
- const excludeSet = new Set(localeRouteExclude || []);
1571
1627
 
1572
1628
  // Match <a ... href="..." ...> — capture the href value
1573
1629
  return html.replace(
@@ -1588,7 +1644,7 @@ function prefixLocaleInternalLinks(html, locale, locales, localeRouteExclude) {
1588
1644
  if (/\.(css|js|json|svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot|pdf|xml|txt)$/i.test(href)) return full;
1589
1645
 
1590
1646
  // Respect localeRouteExclude — these routes stay locale-neutral
1591
- if (excludeSet.has(firstSeg)) return full;
1647
+ if (isLocaleRouteExcluded(withoutSlash, localeRouteExclude)) return full;
1592
1648
 
1593
1649
  // Prefix with locale
1594
1650
  return `${prefix}/${locale}${href}${suffix}`;
@@ -3214,35 +3270,11 @@ async function runPrerender(config) {
3214
3270
  if (!segSet.has(p)) { routeSegments.push(p); segSet.add(p); }
3215
3271
  }
3216
3272
  }
3217
- const localeSet = new Set(locales.map((l) => String(l).toLowerCase()));
3218
- const localeNeutralSegments = routeSegments.filter((seg) => {
3219
- if (!seg) return true;
3220
- const first = seg.split('/')[0].toLowerCase();
3221
- return !localeSet.has(first);
3222
- });
3223
- const paths = new Set();
3224
- paths.add('');
3225
-
3226
- for (const seg of routeSegments) {
3227
- paths.add(seg);
3228
- }
3229
- for (const locale of locales.slice(1)) {
3230
- paths.add(locale);
3231
- for (const seg of localeNeutralSegments) {
3232
- if (!seg) continue;
3233
- paths.add(`${locale}/${seg}`);
3234
- }
3235
- }
3236
- // Default locale also under its slug (e.g. /en/, /en/page-1) so linking is
3237
- // symmetric with other locales; canonical points to root. Skip this when
3238
- // there's only one locale — the duplicates serve no purpose and bloat the
3239
- // output (every page would be written twice: at root AND under /en/).
3240
- if (defaultLocale && locales.length > 1) {
3241
- paths.add(defaultLocale);
3242
- for (const seg of localeNeutralSegments) {
3243
- if (seg !== '') paths.add(`${defaultLocale}/${seg}`);
3244
- }
3245
- }
3273
+ // Expand routeSegments into the full path list: locale-neutral routes as-is,
3274
+ // plus a `<locale>/<route>` variant per locale (skipping localeRouteExclude
3275
+ // prefixes). Factored into a pure function so it's unit-testable without
3276
+ // Puppeteer — see expandLocaleRoutePaths.
3277
+ const paths = expandLocaleRoutePaths({ routeSegments, locales, defaultLocale, localeRouteExclude: config.localeRouteExclude });
3246
3278
 
3247
3279
  const NOT_FOUND_PATH = '__prerender_404__'; // URL path that matches no route so router shows x-route="!*" (404)
3248
3280
  const pathList = [...paths, NOT_FOUND_PATH];
@@ -3355,7 +3387,10 @@ async function runPrerender(config) {
3355
3387
  // Configurable via manifest.prerender.browserRecycleEvery.
3356
3388
  const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
3357
3389
  // Bounds on the recycle handshake and on a single page render. A stuck
3358
- // page must never pin the whole run: each is capped, logged and stepped over.
3390
+ // page must never pin the whole run: each is capped, logged and stepped
3391
+ // over — and a page that hits the ceiling forces a browser recycle, since
3392
+ // a wedged page.evaluate() may have poisoned the shared browser process.
3393
+ // pageTimeout is configurable via manifest.prerender.pageTimeout; 0 disables.
3359
3394
  const drainTimeoutMs = Math.max(0, pre.recycleDrainTimeout ?? 60000);
3360
3395
  const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
3361
3396
  const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
@@ -4814,6 +4849,11 @@ async function runPrerender(config) {
4814
4849
  if (!finished) {
4815
4850
  attemptToken.abandoned = true;
4816
4851
  attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
4852
+ // A timed-out page is presumed to have wedged the browser (e.g.
4853
+ // a hung page.evaluate on a dead Chrome process) — force a
4854
+ // recycle once this path is done, even if a later retry
4855
+ // "succeeds" by reusing the same possibly-poisoned browser.
4856
+ recycleGate.requestRecycle();
4817
4857
  }
4818
4858
  } catch (err) {
4819
4859
  // Unexpected exception escaped processPath (e.g. browser died mid-call).
@@ -4988,4 +5028,11 @@ if (_isDirectEntry) {
4988
5028
  }
4989
5029
 
4990
5030
  // Exports for the CLI bin script and for unit testing.
4991
- export { main, markPrerenderedManifestComponents };
5031
+ export {
5032
+ main,
5033
+ markPrerenderedManifestComponents,
5034
+ normalizeLocaleRouteExclude,
5035
+ isLocaleRouteExcluded,
5036
+ expandLocaleRoutePaths,
5037
+ prefixLocaleInternalLinks,
5038
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.39",
3
+ "version": "0.5.40",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {