mnfst-render 0.5.44 → 0.5.46

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 +206 -153
  2. package/package.json +2 -2
@@ -27,9 +27,7 @@ async function importFromProject(moduleName) {
27
27
 
28
28
 
29
29
  async function flushAlpineEffects(page) {
30
- // Alpine's scheduler can swallow a nextTick callback (a re-triggered
31
- // already-flushed job is dropped), so this wait must be bounded or a page
32
- // hangs idle until the per-page timeout kills it.
30
+ // Bounded: Alpine's scheduler can swallow a nextTick callback
33
31
  await page
34
32
  .evaluate(() => {
35
33
  return new Promise((resolve) => {
@@ -172,8 +170,7 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
172
170
 
173
171
  // 6. Run component swapping explicitly so components tied to this route render
174
172
  // and trigger any $x accesses that start on-demand data loads.
175
- // Bounded: a stranded component-load promise must not hang the
176
- // pipeline past the render-ready timeout (step 7 would never run).
173
+ // Bounded: a stranded component load must not hang the pipeline.
177
174
  if (window.ManifestComponentsSwapping?.processAll) {
178
175
  try {
179
176
  await Promise.race([
@@ -232,16 +229,16 @@ export function isTransientFrameError(message) {
232
229
  }
233
230
 
234
231
  /**
235
- * Coordinates N render workers around a shared browser that is swapped out
236
- * every `every` pages. Workers hold a slot (acquire/release) for exactly as
237
- * long as they touch the browser; a recycle pauses new slots first, then
238
- * drains the in-flight ones, so no worker can ever be blocked on the gate
239
- * while it is still counted as active. Every wait is bounded: a stuck page
240
- * or a stuck browser swap logs and continues instead of hanging the run.
241
- * Exported for unit tests.
232
+ * Coordinates render workers around a shared browser swapped every `every`
233
+ * completed paths. A recycle pauses new slots, drains in-flight ones, swaps,
234
+ * resumes. Forced recycles are coalesced per browser generation and rate
235
+ * limited by `forcedCooldown` completed paths, unless `healthy()` reports a
236
+ * dead browser. Exported for unit tests.
242
237
  */
243
238
  export function createRecycleGate({
244
239
  every = 40,
240
+ forcedCooldown = Math.max(1, Math.ceil(every / 4)),
241
+ healthy = () => true,
245
242
  recycle = async () => {},
246
243
  drainTimeoutMs = 60000,
247
244
  recycleTimeoutMs = 180000,
@@ -249,60 +246,70 @@ export function createRecycleGate({
249
246
  } = {}) {
250
247
  let active = 0;
251
248
  let pages = 0;
249
+ let sinceForced = Infinity;
250
+ let forced = false;
252
251
  let paused = false;
252
+ let generation = 0;
253
+ let recycles = 0;
254
+ let forcedRecycles = 0;
253
255
  const zeroWaiters = [];
254
256
  const resumeWaiters = [];
255
257
  const wake = (list) => { for (const resolve of list.splice(0)) resolve(); };
258
+ const due = () => every > 0 && (pages >= every || (forced && (sinceForced >= forcedCooldown || !healthy())));
256
259
 
257
260
  return {
258
261
  get active() { return active; },
259
262
  get pages() { return pages; },
260
263
  get paused() { return paused; },
261
- /** Claim a browser slot; blocks while a recycle is pending or running. */
264
+ get generation() { return generation; },
265
+ get forced() { return forced; },
266
+ get recycles() { return recycles; },
267
+ get forcedRecycles() { return forcedRecycles; },
268
+ /** Claim a browser slot; resolves with the browser generation. */
262
269
  async acquire() {
263
270
  while (paused) await new Promise((resolve) => { resumeWaiters.push(resolve); });
264
271
  active++;
272
+ return generation;
265
273
  },
266
274
  release() {
267
275
  active = Math.max(0, active - 1);
268
276
  if (active === 0) wake(zeroWaiters);
269
277
  },
270
- /** Count a page against the recycle threshold. */
271
- countPage() { pages++; },
272
- /** Make the next maybeRecycle() fire (unstable browser after retries). */
273
- requestRecycle() { if (every > 0) pages = Math.max(pages, every); },
274
- /** Swap the browser if the threshold is reached. Never throws. */
278
+ /** Count one completed path. */
279
+ countPage() { pages++; sinceForced++; },
280
+ /** Ask for a fresh browser; ignored if `gen` is already replaced. */
281
+ requestRecycle(gen) {
282
+ if (every <= 0) return false;
283
+ if (gen !== undefined && gen !== generation) return false;
284
+ forced = true;
285
+ return true;
286
+ },
287
+ /** Swap the browser if due. Never throws. */
275
288
  async maybeRecycle() {
276
- if (every <= 0 || paused || pages < every) return false;
277
- paused = true; // no new slots from here on — active can only fall
289
+ if (paused || !due()) return false;
290
+ paused = true;
278
291
  const processed = pages;
292
+ const isForced = pages < every;
279
293
  try {
280
- if (active > 0) {
281
- // Never swap the browser under a live page — a per-page caller
282
- // timeout already bounds how long any page can hold a slot, so
283
- // waiting here cannot deadlock. drainTimeoutMs is configured to
284
- // exceed that per-page timeout (see recycleDrainTimeout default in
285
- // resolveConfig), so hitting it means a page leaked past its own
286
- // timeout without releasing — a bug, not a normal recycle path.
287
- // Log it and keep waiting rather than recycle while it's live.
294
+ while (active > 0) {
288
295
  const drained = await withDeadline(
289
296
  new Promise((resolve) => { zeroWaiters.push(resolve); }),
290
297
  drainTimeoutMs
291
298
  );
292
- if (!drained) {
293
- onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — waiting (a leaked page never released its slot)`);
294
- await new Promise((resolve) => { zeroWaiters.push(resolve); });
295
- }
299
+ if (!drained) onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — waiting (a leaked page never released its slot)`);
296
300
  }
297
- const swapped = await withDeadline(recycle(processed), recycleTimeoutMs);
301
+ recycles++;
302
+ if (isForced) { forcedRecycles++; sinceForced = 0; }
303
+ const swapped = await withDeadline(recycle(processed, { forced: isForced }), recycleTimeoutMs);
298
304
  if (!swapped) onLog(`recycle: browser swap exceeded ${recycleTimeoutMs}ms — continuing`);
299
- pages = 0;
300
305
  return swapped;
301
306
  } catch (err) {
302
307
  onLog(`recycle: failed (${err && err.message ? err.message : err}) — continuing`);
303
- pages = 0;
304
308
  return false;
305
309
  } finally {
310
+ pages = 0;
311
+ forced = false;
312
+ generation++;
306
313
  paused = false;
307
314
  wake(resumeWaiters);
308
315
  }
@@ -310,6 +317,81 @@ export function createRecycleGate({
310
317
  };
311
318
  }
312
319
 
320
+ /**
321
+ * Render `items` through the gate with per-attempt watchdog and retries.
322
+ * `render(item, index, token)` may set `token.cancel` to tear down a wedged
323
+ * attempt; a timed-out attempt keeps its slot until cancelled/settled
324
+ * (bounded by `abandonGraceMs`) so a swap never lands under it.
325
+ * Exported for unit tests.
326
+ */
327
+ export async function runRenderQueue({
328
+ items,
329
+ concurrency = 1,
330
+ gate,
331
+ render,
332
+ pageTimeoutMs = 0,
333
+ maxRetries = 2,
334
+ abandonGraceMs = 15000,
335
+ labelOf = (item) => String(item),
336
+ onRetry = () => {},
337
+ onFail = () => {},
338
+ } = {}) {
339
+ let index = 0;
340
+ const failures = [];
341
+
342
+ async function worker() {
343
+ while (true) {
344
+ const i = index++;
345
+ if (i >= items.length) return;
346
+ const item = items[i];
347
+ const label = labelOf(item);
348
+ let attempt = 0;
349
+ while (true) {
350
+ const token = { abandoned: false, failure: null, cancel: null };
351
+ const gen = await gate.acquire();
352
+ let timedOut = false;
353
+ try {
354
+ const run = Promise.resolve().then(() => render(item, i, token));
355
+ const finished = await withDeadline(run, pageTimeoutMs);
356
+ if (!finished) {
357
+ timedOut = true;
358
+ token.abandoned = true;
359
+ token.failure = { path: label, message: `page render exceeded ${pageTimeoutMs}ms` };
360
+ // Hold the slot until the orphan is torn down
361
+ await withDeadline(Promise.resolve().then(() => token.cancel?.()).catch(() => {}), abandonGraceMs);
362
+ await withDeadline(run.catch(() => {}), abandonGraceMs);
363
+ }
364
+ } catch (err) {
365
+ token.failure = { path: label, message: err && err.message ? err.message : String(err) };
366
+ } finally {
367
+ gate.release();
368
+ }
369
+ if (!token.failure) { gate.countPage(); break; }
370
+ if (attempt >= maxRetries) {
371
+ failures.push(token.failure);
372
+ onFail(token.failure, failures.length);
373
+ gate.countPage();
374
+ gate.requestRecycle(gen);
375
+ break;
376
+ }
377
+ const immediate = isTransientFrameError(token.failure.message);
378
+ // Suspect browser: fresh one before the retry
379
+ if (timedOut || (!immediate && attempt + 1 >= Math.ceil(maxRetries / 2))) {
380
+ gate.requestRecycle(gen);
381
+ await gate.maybeRecycle();
382
+ }
383
+ attempt++;
384
+ onRetry(token.failure, attempt, immediate);
385
+ if (!immediate) await new Promise((r) => setTimeout(r, 500 * attempt));
386
+ }
387
+ await gate.maybeRecycle();
388
+ }
389
+ }
390
+
391
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, () => worker()));
392
+ return failures;
393
+ }
394
+
313
395
  // --- Config ------------------------------------------------------------------
314
396
 
315
397
  function parseArgs() {
@@ -758,20 +840,43 @@ function discoverDataPaths(manifest, rootDir, wildcardBases = [], locales = [])
758
840
  return wildcardBases.some((base) => rest.startsWith(base + '/'));
759
841
  }
760
842
 
843
+ // A source mounts under the wildcard route named after it: `docs/*` for key
844
+ // `docs`, or a nested base ending in the key (`docs/framework/*` for key
845
+ // `framework`).
846
+ const mounts = new Map();
847
+ function mountFor(sourceKey) {
848
+ if (!sourceKey) return null;
849
+ if (mounts.has(sourceKey)) return mounts.get(sourceKey);
850
+ let mount = null;
851
+ if (wildcardBases.includes(sourceKey)) mount = sourceKey;
852
+ else {
853
+ const nested = wildcardBases.filter((b) => b.split('/').pop() === sourceKey);
854
+ if (nested.length === 1) {
855
+ mount = nested[0];
856
+ process.stderr.write(`prerender: data source "${sourceKey}" mounts under ${mount}/*\n`);
857
+ } else if (nested.length > 1) {
858
+ process.stderr.write(`prerender: data source "${sourceKey}" matches ${nested.map((b) => b + '/*').join(', ')}; not mounted (ambiguous)\n`);
859
+ }
860
+ }
861
+ mounts.set(sourceKey, mount);
862
+ return mount;
863
+ }
864
+
761
865
  function expandCandidates(rawPath, sourceKey) {
762
866
  const p = String(rawPath || '').replace(/^\/+|\/+$/g, '');
763
867
  if (!p) return [];
764
868
  const candidates = [p];
765
869
  if (wildcardBases.length === 0) return candidates;
766
- if (!sourceKey || !wildcardBases.includes(sourceKey)) return candidates;
870
+ const mount = mountFor(sourceKey);
871
+ if (!mount) return candidates;
767
872
  const parts = p.split('/');
768
873
  const hasLocalePrefix = parts.length > 1 && localeSet.has(parts[0].toLowerCase());
769
874
  if (hasLocalePrefix) {
770
875
  const locale = parts[0];
771
876
  const rest = parts.slice(1).join('/');
772
- if (rest && !rest.startsWith(sourceKey + '/')) candidates.push(`${locale}/${sourceKey}/${rest}`);
773
- } else if (!p.startsWith(sourceKey + '/')) {
774
- candidates.push(`${sourceKey}/${p}`);
877
+ if (rest && !rest.startsWith(mount + '/')) candidates.push(`${locale}/${mount}/${rest}`);
878
+ } else if (!p.startsWith(mount + '/')) {
879
+ candidates.push(`${mount}/${p}`);
775
880
  }
776
881
  return candidates;
777
882
  }
@@ -3621,17 +3726,13 @@ async function runPrerender(config) {
3621
3726
  console.error(' npm i -D puppeteer-core @sparticuz/chromium');
3622
3727
  process.exit(1);
3623
3728
  }
3624
- // chrome-headless-shell: the new headless mode's captureScreenshot can
3625
- // hang forever on macOS (frames never produced), which wedges og
3626
- // snapshots and, through the shared browser, stalls sibling pages.
3729
+ // Headless shell: new headless can hang captureScreenshot on macOS
3627
3730
  return await puppeteer.default.launch({
3628
3731
  headless: 'shell',
3629
3732
  args: [
3630
3733
  '--no-sandbox',
3631
3734
  '--disable-setuid-sandbox',
3632
- // Concurrency keeps sibling tabs backgrounded; without these flags a
3633
- // backgrounded tab stops producing frames and firing rAF/timers, so
3634
- // its captureScreenshot (og snapshots) and settle waits hang forever.
3735
+ // Background tabs must keep producing frames and timers
3635
3736
  '--disable-background-timer-throttling',
3636
3737
  '--disable-backgrounding-occluded-windows',
3637
3738
  '--disable-renderer-backgrounding',
@@ -3695,9 +3796,10 @@ async function runPrerender(config) {
3695
3796
  every: browserRecycleEvery,
3696
3797
  drainTimeoutMs,
3697
3798
  recycleTimeoutMs,
3799
+ healthy: () => !!browser && browser.connected !== false,
3698
3800
  onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
3699
- recycle: async (processed) => {
3700
- process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
3801
+ recycle: async (processed, { forced } = {}) => {
3802
+ process.stdout.write(`prerender: recycling browser (${forced ? `forced after ${processed}` : `processed ${processed}`} pages)\n`);
3701
3803
  await closeBrowser(browser);
3702
3804
  browser = await launchBrowserBounded();
3703
3805
  },
@@ -3855,6 +3957,7 @@ async function runPrerender(config) {
3855
3957
  // No recycle can run while the caller holds a gate slot, so `browser` is
3856
3958
  // live for the whole call.
3857
3959
  const page = await browser.newPage();
3960
+ if (attempt) attempt.cancel = () => page.close();
3858
3961
  // Render at a typical desktop viewport so layouts dependent on viewport
3859
3962
  // width (responsive flex/grid, container queries, media queries) settle
3860
3963
  // into their desktop variant. Without this the headless default (often
@@ -4094,8 +4197,7 @@ async function runPrerender(config) {
4094
4197
  // small ones settle in well under 1 second.
4095
4198
  await page.waitForNetworkIdle({ idleTime: 1000, timeout: 8000 }).catch(() => { });
4096
4199
 
4097
- // DOM stability after network idle. Hard-capped: a page that animates
4098
- // continuously must not reset the quiet window forever.
4200
+ // DOM stability after network idle (hard-capped).
4099
4201
  await page.evaluate(() => {
4100
4202
  return new Promise((resolve) => {
4101
4203
  const observer = new MutationObserver(() => {
@@ -4136,8 +4238,7 @@ async function runPrerender(config) {
4136
4238
  const ogImageHandled = !!config.seo.meta?.image
4137
4239
  || await page.evaluate(() => !!document.head.querySelector('meta[property="og:image"]'));
4138
4240
  if (!ogImageHandled) {
4139
- // Bounded: a CDP call that never returns must cost this page its og
4140
- // image, not the whole render (the meta fallback image covers it).
4241
+ // Bounded: a hung CDP call costs only this page's og image
4141
4242
  earlySnapshotUrl = await Promise.race([
4142
4243
  takeOgSnapshot(page, config.output, is404 ? '__404__' : pathSeg, globalAssetSig, ogCacheDir),
4143
4244
  new Promise((resolve) => setTimeout(() => resolve(null), 30000)),
@@ -4315,6 +4416,25 @@ async function runPrerender(config) {
4315
4416
  await page.evaluate(() => {
4316
4417
  document.querySelectorAll('[x-markdown]').forEach((el) => {
4317
4418
  if (!el.textContent.trim() && !el.innerHTML.trim()) return;
4419
+ // Only FILE-BACKED markdown is safe to freeze: its content is baked and
4420
+ // the runtime would only re-fetch and re-render the same thing (with an
4421
+ // opacity flash, or a wipe when a route-keyed source starts empty). The
4422
+ // plugin stamps those with data-mnfst-md-src before fetching. An inline
4423
+ // reactive value (x-markdown="note") must KEEP its binding — stripping
4424
+ // it froze live-updating markdown mirrors on prerendered pages.
4425
+ const src = el.getAttribute('data-mnfst-md-src');
4426
+ const literal = /^\s*(['"`]).*\1\s*$/.test(el.getAttribute('x-markdown') || '');
4427
+ if (!src && !literal) {
4428
+ // Reactive: keep the binding, still clean the plugin's leftover styles.
4429
+ const s = el.getAttribute('style') || '';
4430
+ if (s) {
4431
+ const c = s.replace(/\bopacity\s*:\s*0(?:\.\d+)?\s*;?/gi, '')
4432
+ .replace(/\btransition\s*:\s*opacity[^;]*;?/gi, '')
4433
+ .replace(/;\s*;/g, ';').replace(/^\s*;\s*|\s*;\s*$/g, '').trim();
4434
+ if (c) el.setAttribute('style', c); else el.removeAttribute('style');
4435
+ }
4436
+ return;
4437
+ }
4318
4438
  el.removeAttribute('x-markdown');
4319
4439
  // Clean up opacity-0 + transition inline styles the plugin left behind.
4320
4440
  const style = el.getAttribute('style') || '';
@@ -4744,14 +4864,17 @@ async function runPrerender(config) {
4744
4864
  if (name.startsWith(':')) boundAttr = name.slice(1);
4745
4865
  else if (name.startsWith('x-bind:')) boundAttr = name.slice('x-bind:'.length);
4746
4866
  if (boundAttr) {
4867
+ // Removing :foo triggers Alpine's binding teardown, which
4868
+ // restores the bound attribute to its pre-binding value
4869
+ // (empty for clones whose template had no static class).
4870
+ // Snapshot the eval'd value and re-set it after removal so
4871
+ // the baked attribute survives the unbind. A binding whose
4872
+ // value evaluated to null (no attribute) is removed all the
4873
+ // same — left in place it re-evaluates at runtime outside
4874
+ // the loop scope and throws on every render.
4747
4875
  const concrete = node.getAttribute(boundAttr);
4876
+ node.removeAttribute(name);
4748
4877
  if (concrete != null && String(concrete).trim() !== '') {
4749
- // Removing :foo triggers Alpine's binding teardown, which
4750
- // restores the bound attribute to its pre-binding value
4751
- // (empty for clones whose template had no static class).
4752
- // Snapshot the eval'd value and re-set it after removal so
4753
- // the baked attribute survives the unbind.
4754
- node.removeAttribute(name);
4755
4878
  node.setAttribute(boundAttr, concrete);
4756
4879
  }
4757
4880
  continue;
@@ -5190,103 +5313,32 @@ async function runPrerender(config) {
5190
5313
  }
5191
5314
  }
5192
5315
 
5193
- // Phase 1: Puppeteer — render base paths, cache raw DOM for substitution.
5194
- // Any failures (e.g. transient navigation timeouts) are retried up to
5195
- // `maxRetries` times with a short backoff before being reported as fatal.
5196
- //
5197
- // Browser recycling: after every `browserRecycleEvery` successful pages the
5198
- // gate pauses new work, drains the in-flight pages, swaps the browser and
5199
- // resumes. Workers hold a gate slot only while they touch the browser.
5200
- //
5201
- // A failure whose message matches a transient Puppeteer/browser-recycle race
5202
- // (detached frame, destroyed execution context) is requeued immediately —
5203
- // no backoff — since waiting doesn't help a race and each attempt already
5204
- // gets its own fresh `pageTimeoutMs` budget.
5316
+ // Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
5205
5317
  async function renderPuppeteerPaths(paths) {
5206
- let index = 0;
5207
- const total = paths.length;
5208
-
5209
- async function worker() {
5210
- while (true) {
5211
- const i = index++;
5212
- if (i >= paths.length) return;
5213
- const pathSeg = paths[i];
5214
- const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
5215
- let attempt = 0;
5216
- while (true) {
5217
- // Per-attempt result: `failedPaths` is shared, so a retry must never
5218
- // read or pop entries another worker pushed.
5219
- const attemptToken = { abandoned: false, failure: null };
5220
- await recycleGate.acquire();
5221
- try {
5222
- // Watchdog: a wedged renderer never returns, so cap the attempt.
5223
- const finished = await withDeadline(
5224
- processPath(pathSeg, i, {
5225
- attempt: attemptToken,
5226
- onRawHtml: (seg, html) => {
5227
- if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
5228
- },
5229
- onLocaleDependentContent: promoteLocaleVariants,
5230
- }),
5231
- pageTimeoutMs
5232
- );
5233
- if (!finished) {
5234
- attemptToken.abandoned = true;
5235
- attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
5236
- // A timed-out page is presumed to have wedged the browser (e.g.
5237
- // a hung page.evaluate on a dead Chrome process) — force a
5238
- // recycle once this path is done, even if a later retry
5239
- // "succeeds" by reusing the same possibly-poisoned browser.
5240
- recycleGate.requestRecycle();
5241
- }
5242
- } catch (err) {
5243
- // Unexpected exception escaped processPath (e.g. browser died mid-call).
5244
- attemptToken.failure = {
5245
- path: displayPath,
5246
- message: err && err.message ? err.message : String(err),
5247
- };
5248
- } finally {
5249
- recycleGate.release();
5250
- }
5251
- if (!attemptToken.failure) {
5252
- recycleGate.countPage();
5253
- break; // success
5254
- }
5255
- if (attempt >= maxRetries) {
5256
- // Exhausted retries — likely an unstable browser (e.g. cascading
5257
- // "detached Frame" errors). Force the next path onto a fresh one.
5258
- failedPaths.push(attemptToken.failure);
5259
- if (failedPaths.length <= 10) {
5260
- process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
5261
- }
5262
- recycleGate.countPage();
5263
- recycleGate.requestRecycle();
5264
- break;
5265
- }
5266
- const immediateRequeue = isTransientFrameError(attemptToken.failure.message);
5267
- // Halfway through retries with no success → preemptively recycle the
5268
- // browser before the next attempt. This unblocks cascading frame
5269
- // failures where the browser process needs a fresh start. Skipped
5270
- // for a transient frame error — that's likely already a recycle
5271
- // race, not a signal to force another one.
5272
- if (!immediateRequeue && attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
5273
- recycleGate.requestRecycle();
5274
- await recycleGate.maybeRecycle();
5275
- }
5276
- attempt++;
5277
- process.stderr.write(
5278
- `prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})${immediateRequeue ? ' — immediate requeue' : ''}\n`
5279
- );
5280
- if (!immediateRequeue) await new Promise((r) => setTimeout(r, 500 * attempt));
5281
- }
5282
- // Attempt recycle after each completed path (only one worker performs
5283
- // it; the rest block in acquire()).
5284
- await recycleGate.maybeRecycle();
5285
- }
5286
- }
5287
- await Promise.all(
5288
- Array.from({ length: Math.min(concurrency, total || 1) }, () => worker())
5289
- );
5318
+ const failures = await runRenderQueue({
5319
+ items: paths,
5320
+ concurrency,
5321
+ gate: recycleGate,
5322
+ pageTimeoutMs,
5323
+ maxRetries,
5324
+ labelOf: (seg) => (seg === '' ? '/' : (seg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + seg)),
5325
+ render: (pathSeg, i, attempt) => processPath(pathSeg, i, {
5326
+ attempt,
5327
+ onRawHtml: (seg, html) => {
5328
+ if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
5329
+ },
5330
+ onLocaleDependentContent: promoteLocaleVariants,
5331
+ }),
5332
+ onRetry: (failure, attempt, immediate) => {
5333
+ process.stderr.write(
5334
+ `prerender: retrying ${failure.path} (${failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})${immediate ? ' — immediate requeue' : ''}\n`
5335
+ );
5336
+ },
5337
+ onFail: (failure, count) => {
5338
+ if (count <= 10) process.stderr.write(`prerender: failed ${failure.path}: ${failure.message}\n`);
5339
+ },
5340
+ });
5341
+ failedPaths.push(...failures);
5290
5342
  }
5291
5343
 
5292
5344
  try {
@@ -5433,6 +5485,7 @@ if (_isDirectEntry) {
5433
5485
  // Exports for the CLI bin script and for unit testing.
5434
5486
  export {
5435
5487
  main,
5488
+ discoverRoutes,
5436
5489
  acquireOutputLock,
5437
5490
  outputLockPath,
5438
5491
  markPrerenderedManifestComponents,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.44",
3
+ "version": "0.5.46",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "license": "MIT",
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "git+https://github.com/andrewmatlock/Manifest.git",
35
+ "url": "git+https://github.com/Manifest-X/Manifest.git",
36
36
  "directory": "packages/render"
37
37
  }
38
38
  }