mnfst-render 0.4.0 → 0.4.1

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 +333 -119
  2. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { readFileSync, readSync, mkdirSync, writeFileSync, existsSync, rmSync, s
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { join, resolve, dirname, relative, basename, sep } from 'node:path';
8
8
  import { createServer } from 'node:http';
9
+ import { cpus } from 'node:os';
9
10
  import { createRequire } from 'node:module';
10
11
  import { fileURLToPath } from 'node:url';
11
12
 
@@ -23,37 +24,6 @@ async function importFromProject(moduleName) {
23
24
  }
24
25
  }
25
26
 
26
- /**
27
- * localechange reloads localized sources asynchronously; waitForNetworkIdle can finish before
28
- * Alpine.store('data') updates. Without this, x-for / $modify('items') lists often snapshot empty.
29
- */
30
- async function waitForAlpineDataStoresSettled(page, timeoutMs = 12000) {
31
- const interval = 100;
32
- const deadline = Date.now() + timeoutMs;
33
- while (Date.now() < deadline) {
34
- const ok = await page
35
- .evaluate(() => {
36
- try {
37
- const Alpine = window.Alpine;
38
- if (!Alpine?.store) return true;
39
- const d = Alpine.store('data');
40
- if (!d) return true;
41
- if (d._localeChanging) return false;
42
- for (const k of Object.keys(d)) {
43
- if (!k.startsWith('_') || !k.endsWith('_state')) continue;
44
- const s = d[k];
45
- if (s && typeof s === 'object' && s.loading) return false;
46
- }
47
- return true;
48
- } catch {
49
- return true;
50
- }
51
- })
52
- .catch(() => true);
53
- if (ok) return;
54
- await new Promise((r) => setTimeout(r, interval));
55
- }
56
- }
57
27
 
58
28
  async function flushAlpineEffects(page) {
59
29
  await page
@@ -96,86 +66,72 @@ function logicalPathToVisibilityNormalizedPath(pathSeg, locales) {
96
66
  }
97
67
 
98
68
  /**
99
- * After locale + route sync: run component swapping explicitly, then wait until Alpine data stores
100
- * are settled. We call ManifestComponentsSwapping.processAll() directly because swapping only
101
- * subscribes to manifest:route-change if window.ManifestRouting already exists at initialize() time;
102
- * when the router loads after components, that listener is never registered and no second
103
- * manifest:components-processed fires.
69
+ * Set locale, dispatch route/locale events, call component swapping, then wait for
70
+ * manifest:render-ready the authoritative signal from the data plugin that all tracked
71
+ * sources have settled for the active locale.
72
+ *
73
+ * Falls back to a timeout if the data plugin is absent or predates manifest:render-ready,
74
+ * so this is backward-compatible with any Manifest project.
104
75
  */
105
- async function waitForManifestPrerenderPipeline(page, { allLocales, currentLocale, timeoutMs }) {
76
+ async function waitForManifestRenderReady(page, { allLocales, currentLocale, timeoutMs }) {
106
77
  const result = await page
107
78
  .evaluate(
108
79
  async ({ localeList, loc, ms }) => {
109
- const sleep = (n) => new Promise((r) => setTimeout(r, n));
110
- const deadline = Date.now() + ms;
111
-
112
- const checkData = () => {
113
- try {
114
- const Alpine = window.Alpine;
115
- if (!Alpine?.store) return true;
116
- const d = Alpine.store('data');
117
- if (!d) return true;
118
- if (d._localeChanging) return false;
119
- for (const k of Object.keys(d)) {
120
- if (!k.startsWith('_') || !k.endsWith('_state')) continue;
121
- const s = d[k];
122
- if (s && typeof s === 'object' && s.loading) return false;
123
- }
124
- return true;
125
- } catch {
126
- return true;
127
- }
128
- };
129
-
130
80
  try {
131
81
  const locales = Array.isArray(localeList) ? localeList : [];
82
+
83
+ // 1. Align locale state before dispatching any events.
132
84
  if (loc && typeof loc === 'string') {
133
- try {
134
- document.documentElement.lang = loc;
135
- } catch {
136
- /* no-op */
137
- }
85
+ try { document.documentElement.lang = loc; } catch { /* no-op */ }
138
86
  }
139
- const store = typeof Alpine !== 'undefined' && Alpine.store ? Alpine.store('locale') : null;
140
- if (store) {
141
- if (!Array.isArray(store.available) || store.available.length === 0) {
142
- store.available = locales.slice();
87
+ const localeStore = typeof Alpine !== 'undefined' && Alpine.store
88
+ ? Alpine.store('locale') : null;
89
+ if (localeStore) {
90
+ if (!Array.isArray(localeStore.available) || localeStore.available.length === 0) {
91
+ localeStore.available = locales.slice();
143
92
  } else {
144
- store.available = Array.from(new Set([...store.available, ...locales]));
145
- }
146
- if (loc && typeof loc === 'string') {
147
- store.current = loc;
93
+ localeStore.available = Array.from(new Set([...localeStore.available, ...locales]));
148
94
  }
95
+ if (loc && typeof loc === 'string') localeStore.current = loc;
149
96
  }
150
97
 
151
- if (loc && typeof loc === 'string') {
152
- window.dispatchEvent(new CustomEvent('localechange', { detail: { locale: loc } }));
153
- }
154
-
155
- const rawRoute = window.ManifestRoutingNavigation?.getCurrentRoute?.() ?? window.location.pathname;
98
+ // 2. Compute normalised route path (strips locale prefix, matches router logic).
99
+ const rawRoute = window.ManifestRoutingNavigation?.getCurrentRoute?.()
100
+ ?? window.location.pathname;
156
101
  const clean = String(rawRoute || '/').replace(/^\/+|\/+$/g, '');
157
102
  const parts = clean ? clean.split('/') : [];
158
103
  const logical =
159
104
  parts.length > 0 && locales.includes(parts[0])
160
105
  ? '/' + parts.slice(1).join('/')
161
- : clean
162
- ? '/' + clean
163
- : '/';
106
+ : clean ? '/' + clean : '/';
164
107
  const to = logical === '//' ? '/' : logical;
165
108
  const normalizedPath =
166
109
  typeof to === 'string' && to !== '/' ? to.replace(/^\/|\/$/g, '') : '/';
167
110
 
168
- window.dispatchEvent(
169
- new CustomEvent('manifest:route-change', {
170
- detail: {
171
- from: to,
172
- to,
173
- normalizedPath,
174
- },
175
- })
176
- );
111
+ // 3. Register the manifest:render-ready listener BEFORE dispatching events so we
112
+ // never miss a fast-settling response. Falls back to timeout for older data plugins.
113
+ const renderReadyPromise = new Promise((resolve) => {
114
+ const onReady = (e) => resolve({ ok: true, locale: e.detail?.locale });
115
+ window.addEventListener('manifest:render-ready', onReady, { once: true });
116
+ setTimeout(() => {
117
+ window.removeEventListener('manifest:render-ready', onReady);
118
+ resolve({ ok: false, reason: 'timeout' });
119
+ }, ms);
120
+ });
121
+
122
+ // 4. Dispatch locale change — triggers localized source reloads in the data plugin.
123
+ if (loc && typeof loc === 'string') {
124
+ window.dispatchEvent(new CustomEvent('localechange', { detail: { locale: loc } }));
125
+ }
126
+
127
+ // 5. Dispatch route change — ensures router visibility and head content are current.
128
+ window.dispatchEvent(new CustomEvent('manifest:route-change', {
129
+ detail: { from: to, to, normalizedPath },
130
+ }));
177
131
  window.dispatchEvent(new PopStateEvent('popstate'));
178
132
 
133
+ // 6. Run component swapping explicitly so components tied to this route render
134
+ // and trigger any $x accesses that start on-demand data loads.
179
135
  if (window.ManifestComponentsSwapping?.processAll) {
180
136
  try {
181
137
  await window.ManifestComponentsSwapping.processAll(normalizedPath);
@@ -184,33 +140,18 @@ async function waitForManifestPrerenderPipeline(page, { allLocales, currentLocal
184
140
  }
185
141
  }
186
142
 
187
- while (Date.now() < deadline) {
188
- if (checkData()) {
189
- return { ok: true };
190
- }
191
- await sleep(50);
192
- }
193
- return {
194
- ok: false,
195
- reason: 'timeout',
196
- dataOk: checkData(),
197
- hadSwapping: !!window.ManifestComponentsSwapping?.processAll,
198
- };
143
+ // 7. Await the authoritative signal (or timeout fallback).
144
+ return await renderReadyPromise;
199
145
  } catch (err) {
200
146
  return { ok: false, reason: 'error', message: String(err?.message || err) };
201
147
  }
202
148
  },
203
- {
204
- localeList: allLocales,
205
- loc: currentLocale,
206
- ms: timeoutMs,
207
- }
149
+ { localeList: allLocales, loc: currentLocale, ms: timeoutMs }
208
150
  )
209
151
  .catch((e) => ({ ok: false, reason: 'evaluate', message: String(e) }));
210
152
 
211
153
  if (!result?.ok) {
212
- const parts = [`prerender: pipeline wait incomplete (${result?.reason ?? 'unknown'})`];
213
- if (result?.dataOk === false) parts.push('data still loading');
154
+ const parts = [`prerender: render-ready wait incomplete (${result?.reason ?? 'unknown'})`];
214
155
  if (result?.message) parts.push(result.message);
215
156
  process.stdout.write(`${parts.join('; ')}\n`);
216
157
  }
@@ -289,7 +230,14 @@ function resolveConfig() {
289
230
  redirects: Array.isArray(pre.redirects) ? pre.redirects : [],
290
231
  wait: cli.wait ?? pre.wait ?? null,
291
232
  waitAfterIdle: Math.max(0, cli.waitAfterIdle ?? pre.waitAfterIdle ?? 0),
292
- concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? 6),
233
+ concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? Math.max(4, cpus().length - 1)),
234
+ /** Default: generate locale variant pages via Node.js text substitution rather than Puppeteer.
235
+ * Set manifest.prerender.localeSubstitution=false to always use Puppeteer for every locale. */
236
+ localeSubstitution: pre.localeSubstitution !== false,
237
+ /** Locales to always render with Puppeteer even when localeSubstitution is enabled (e.g. RTL). */
238
+ localeSubstitutionExclude: Array.isArray(pre.localeSubstitutionExclude)
239
+ ? pre.localeSubstitutionExclude.map(String)
240
+ : [],
293
241
  dryRun: !!cli.dryRun,
294
242
  debugPrerender: !!(cli.debugPrerender ?? pre.debugPrerender),
295
243
  pipelineTimeout: Math.max(3000, Number(pre.pipelineTimeout) || 25000),
@@ -1168,6 +1116,185 @@ function hasOtherOgMeta(html) {
1168
1116
  return /<meta[^>]*property="og:(?!locale(?::alternate)?")[^"]*"[^>]*>/i.test(html);
1169
1117
  }
1170
1118
 
1119
+ // --- Locale text substitution (Node.js post-processing — no Puppeteer for locale variants) ------
1120
+
1121
+ /**
1122
+ * Load the key→value content data for every locale from every CSV that has locale columns.
1123
+ * Returns Map<locale, { key: value }>.
1124
+ */
1125
+ function loadAllLocaleContentData(manifest, rootDir, locales) {
1126
+ const data = manifest?.data;
1127
+ if (!data || typeof data !== 'object') return new Map();
1128
+
1129
+ const csvFiles = [];
1130
+ const seen = new Set();
1131
+ const addCsv = (ref) => {
1132
+ if (typeof ref !== 'string' || !ref.endsWith('.csv')) return;
1133
+ const p = join(rootDir, ref.startsWith('/') ? ref.slice(1) : ref);
1134
+ if (!seen.has(p)) { seen.add(p); csvFiles.push(p); }
1135
+ };
1136
+
1137
+ for (const [, value] of Object.entries(data)) {
1138
+ if (typeof value === 'string') { addCsv(value); continue; }
1139
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
1140
+ if (value.locales) {
1141
+ const refs = Array.isArray(value.locales) ? value.locales : [value.locales];
1142
+ refs.forEach(addCsv);
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ const result = new Map();
1148
+ for (const locale of locales) {
1149
+ const merged = {};
1150
+ for (const csvPath of csvFiles) {
1151
+ Object.assign(merged, parseCsvToKeyValue(csvPath, locale));
1152
+ }
1153
+ result.set(locale, merged);
1154
+ }
1155
+ return result;
1156
+ }
1157
+
1158
+ /**
1159
+ * Build [[defaultValue, targetValue], ...] replacement pairs sorted longest-first.
1160
+ * Skips empty strings and identical pairs to reduce noise.
1161
+ */
1162
+ function buildSubstitutionPairs(defaultLocaleData, targetLocaleData) {
1163
+ const pairs = [];
1164
+ for (const [key, rawDefault] of Object.entries(defaultLocaleData)) {
1165
+ const rawTarget = targetLocaleData[key];
1166
+ if (rawTarget == null || rawDefault == null) continue;
1167
+ const from = String(rawDefault).trim();
1168
+ const to = String(rawTarget).trim();
1169
+ if (!from || from === to) continue;
1170
+ pairs.push([from, to]);
1171
+ }
1172
+ pairs.sort((a, b) => b[0].length - a[0].length);
1173
+ return pairs;
1174
+ }
1175
+
1176
+ /**
1177
+ * Apply locale text substitution to rendered HTML.
1178
+ * Replaces content in text nodes (between > and <) and in key attributes:
1179
+ * content, alt, title, placeholder, aria-label.
1180
+ */
1181
+ function applyLocaleSubstitution(html, pairs) {
1182
+ if (!pairs || !pairs.length) return html;
1183
+
1184
+ // 1. Text nodes: walk content between '>' and '<'
1185
+ let out = '';
1186
+ let pos = 0;
1187
+ while (pos < html.length) {
1188
+ const gtPos = html.indexOf('>', pos);
1189
+ if (gtPos === -1) { out += html.slice(pos); break; }
1190
+ const ltPos = html.indexOf('<', gtPos + 1);
1191
+ if (ltPos === -1) { out += html.slice(pos); break; }
1192
+ out += html.slice(pos, gtPos + 1);
1193
+ let text = html.slice(gtPos + 1, ltPos);
1194
+ if (text.trim()) {
1195
+ for (const [from, to] of pairs) {
1196
+ if (text.includes(from)) text = text.split(from).join(to);
1197
+ }
1198
+ }
1199
+ out += text;
1200
+ pos = ltPos;
1201
+ }
1202
+
1203
+ // 2. Selected attributes that carry visible text
1204
+ out = out.replace(
1205
+ /(\s(?:content|alt|title|placeholder|aria-label)=["'])([^"']*)(['"])/g,
1206
+ (match, prefix, val, suffix) => {
1207
+ let v = val;
1208
+ for (const [from, to] of pairs) {
1209
+ if (v.includes(from)) v = v.split(from).join(to);
1210
+ }
1211
+ return `${prefix}${v}${suffix}`;
1212
+ }
1213
+ );
1214
+
1215
+ return out;
1216
+ }
1217
+
1218
+ /**
1219
+ * Generate a locale variant's HTML entirely in Node.js from a cached base-path DOM snapshot.
1220
+ * Applies text substitution then the full Node.js post-processing pipeline.
1221
+ * Returns { html, utilityBlocks }.
1222
+ */
1223
+ function generateLocaleVariantHtml({
1224
+ rawHtml, pathSeg, targetLocale, locales, defaultLocale,
1225
+ config, manifest, routerBasePath, tailwindBuilt, bundleUtilities,
1226
+ substitutionPairs,
1227
+ }) {
1228
+ let html = rawHtml;
1229
+
1230
+ // Update lang attribute before resolveHeadXBindings so it sees the right locale
1231
+ html = html.replace(/(<html\b[^>]*)\s+lang=["'][^"']*["']/i, `$1 lang="${targetLocale}"`);
1232
+ if (!/<html\b[^>]*\slang=/i.test(html)) {
1233
+ html = html.replace(/(<html\b)/i, `$1 lang="${targetLocale}"`);
1234
+ }
1235
+
1236
+ // Apply locale text substitution
1237
+ html = applyLocaleSubstitution(html, substitutionPairs);
1238
+
1239
+ // Standard Node.js post-processing (same sequence as processPath)
1240
+ html = stripDevOnlyContent(html);
1241
+ html = stripInjectedPluginScripts(html);
1242
+ if (tailwindBuilt) html = stripRuntimeTailwindArtifacts(html);
1243
+
1244
+ const pageUtilityBlocks = [];
1245
+ if (bundleUtilities) {
1246
+ const extracted = extractUtilityStyleBlocks(html);
1247
+ html = extracted.html;
1248
+ for (const b of extracted.blocks) pageUtilityBlocks.push(b);
1249
+ }
1250
+
1251
+ if (tailwindBuilt) {
1252
+ html = injectBeforeHeadClose(
1253
+ html,
1254
+ `<link rel="stylesheet" href="${buildRootAssetPath(routerBasePath, 'prerender.tailwind.css')}">`
1255
+ );
1256
+ }
1257
+
1258
+ html = stripDuplicatedLoopDirectives(html);
1259
+ html = stripPrerenderedXDataDirectives(html);
1260
+
1261
+ const content = loadContentForPrerender(manifest, config.root, targetLocale);
1262
+ html = resolveHeadXBindings(html, { manifest, content });
1263
+
1264
+ html = stripPrerenderDynamicBindings(html);
1265
+ html = stripPrerenderBakedRadioCheckedForXModel(html);
1266
+ html = stripRedundantImgSrcBindings(html);
1267
+ html = stripEmptyInlineMaskStyles(html);
1268
+ html = stripResolvedXIconDirectives(html);
1269
+ html = stripPrerenderHydrateMarkers(html);
1270
+
1271
+ const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
1272
+ html = rewriteHtmlAssetPaths(html, fileSegments.length);
1273
+
1274
+ const liveBase = config.liveUrl.replace(/\/$/, '');
1275
+ const canonicalHreflang = buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, liveBase);
1276
+ const ogLocale = buildOgLocale(pathSeg, locales, defaultLocale);
1277
+ const injectOgLocale = ogLocale && hasOtherOgMeta(html);
1278
+ if (injectOgLocale) html = stripOgLocaleFromHead(html);
1279
+
1280
+ const routeEx = config.localeRouteExclude || [];
1281
+ const routeMeta = routeEx.length > 0
1282
+ ? `<meta name="manifest:locale-route-exclude" content="${JSON.stringify(routeEx).replace(/"/g, '&quot;')}">\n`
1283
+ : '';
1284
+ const baseMeta = routerBasePath !== null
1285
+ ? `<meta name="manifest:router-base" content="${String(routerBasePath).replace(/"/g, '&quot;')}">\n`
1286
+ : '';
1287
+ const routeDepth = fileSegments.length;
1288
+
1289
+ html = html.replace(
1290
+ '</head>',
1291
+ `${canonicalHreflang}${injectOgLocale ? ogLocale : ''}${routeMeta}${baseMeta}<meta name="manifest:prerendered" content="1">\n<meta name="manifest:router-base-depth" content="${routeDepth}">\n</head>`
1292
+ );
1293
+ html = markPrerenderedManifestComponents(html);
1294
+
1295
+ return { html, utilityBlocks: pageUtilityBlocks };
1296
+ }
1297
+
1171
1298
  // --- Resolve $x bindings in <head> (data-head meta/link are injected with :attr="$x.path" but never evaluated) ---
1172
1299
 
1173
1300
  function loadContentForPrerender(manifest, rootDir, locale) {
@@ -1546,18 +1673,60 @@ async function runPrerender(config) {
1546
1673
  const pathTotal = pathList.length;
1547
1674
  const failedPaths = [];
1548
1675
  const debugRows = [];
1549
- process.stdout.write(`Prerendering ${pathTotal} path(s)...\n`);
1676
+
1677
+ // --- Two-phase rendering: Puppeteer for base paths, Node.js substitution for locale variants ---
1678
+ // Categorise paths: locale-prefixed paths (en/about, fr/about, ...) are "locale variants"
1679
+ // and can be generated from the corresponding base path's DOM snapshot + text substitution.
1680
+ // This eliminates Puppeteer for every locale × route combination beyond the base routes.
1681
+ const localeSubstEnabled = config.localeSubstitution;
1682
+ const localeSubstExclude = new Set(config.localeSubstitutionExclude || []);
1683
+ const puppeteerPaths = [];
1684
+ const localeVariantPaths = []; // { pathSeg, basePathSeg, targetLocale }
1685
+
1686
+ for (const seg of pathList) {
1687
+ if (!localeSubstEnabled || seg === NOT_FOUND_PATH || !seg) {
1688
+ puppeteerPaths.push(seg);
1689
+ continue;
1690
+ }
1691
+ const firstPart = seg.split('/')[0];
1692
+ if (localeSet.has(firstPart) && !localeSubstExclude.has(firstPart)) {
1693
+ const basePathSeg = seg.slice(firstPart.length + 1); // strip 'fr/'
1694
+ localeVariantPaths.push({ pathSeg: seg, basePathSeg: basePathSeg || '', targetLocale: firstPart });
1695
+ } else {
1696
+ puppeteerPaths.push(seg);
1697
+ }
1698
+ }
1699
+
1700
+ // Preload locale data for text substitution (all CSV sources with locale columns)
1701
+ const allLocaleData = loadAllLocaleContentData(manifest, config.root, locales);
1702
+ const substitutionMaps = new Map(); // locale → [[from, to], ...]
1703
+ for (const locale of locales) {
1704
+ if (locale === defaultLocale) {
1705
+ substitutionMaps.set(locale, []); // default locale: no text substitution needed
1706
+ } else {
1707
+ substitutionMaps.set(locale, buildSubstitutionPairs(
1708
+ allLocaleData.get(defaultLocale) || {},
1709
+ allLocaleData.get(locale) || {}
1710
+ ));
1711
+ }
1712
+ }
1713
+
1714
+ // baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
1715
+ const baseHtmlCache = new Map();
1716
+ const puppeteerTotal = puppeteerPaths.length;
1717
+
1718
+ process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution)...\n`);
1550
1719
 
1551
1720
  function pushDebug(row) {
1552
1721
  if (!config.debugPrerender) return;
1553
1722
  debugRows.push(row);
1554
1723
  }
1555
1724
 
1556
- async function processPath(pathSeg, pathIndex) {
1725
+ async function processPath(pathSeg, pathIndex, { onRawHtml } = {}) {
1557
1726
  const is404 = pathSeg === NOT_FOUND_PATH;
1558
1727
  const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
1559
1728
  const displayPath = pathSeg === '' ? '/' : pathname;
1560
- process.stdout.write(` [ ${pathIndex + 1}/${pathTotal} ] ${displayPath}\n`);
1729
+ process.stdout.write(` [ ${pathIndex + 1}/${puppeteerTotal} ] ${displayPath}\n`);
1561
1730
  const url = `${config.localUrl}${pathname}`;
1562
1731
  const fileSegments = is404 ? [] : pathToFileSegments(pathSeg ? `/${pathSeg}` : '/');
1563
1732
  const outDir = is404 ? config.output : join(config.output, ...fileSegments);
@@ -1646,16 +1815,16 @@ async function runPrerender(config) {
1646
1815
  });
1647
1816
  }).catch(() => { });
1648
1817
 
1649
- // Locale + route sync, then wait for manifest:components-processed (swapping) and settled data stores
1650
- // before snapshot. Listener is installed inside the page before dispatch (see waitForManifestPrerenderPipeline).
1651
- await waitForManifestPrerenderPipeline(page, {
1818
+ // Set locale, dispatch route/locale events, call component swapping, then wait for
1819
+ // manifest:render-ready the single authoritative signal that all data sources have
1820
+ // settled for this locale/route. Falls back to timeout for older data plugins.
1821
+ await waitForManifestRenderReady(page, {
1652
1822
  allLocales: locales,
1653
1823
  currentLocale,
1654
1824
  timeoutMs: config.pipelineTimeout,
1655
1825
  });
1656
1826
 
1657
- await page.waitForNetworkIdle({ idleTime: 800, timeout: 12000 }).catch(() => { });
1658
- await waitForAlpineDataStoresSettled(page, 10000);
1827
+ // Flush any remaining Alpine microtask effects after the render-ready signal.
1659
1828
  await flushAlpineEffects(page);
1660
1829
 
1661
1830
  if (config.debugPrerender) {
@@ -2066,6 +2235,8 @@ async function runPrerender(config) {
2066
2235
  });
2067
2236
 
2068
2237
  let html = await page.evaluate(() => document.documentElement.outerHTML);
2238
+ // Cache raw DOM snapshot for locale variant generation (before any Node.js transforms).
2239
+ if (typeof onRawHtml === 'function') onRawHtml(pathSeg, html);
2069
2240
  if (config.debugPrerender) {
2070
2241
  const post = await page.evaluate(() => {
2071
2242
  const templates = document.querySelectorAll('template[x-for]').length;
@@ -2143,22 +2314,65 @@ async function runPrerender(config) {
2143
2314
  }
2144
2315
  }
2145
2316
 
2317
+ // Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
2146
2318
  try {
2147
2319
  let index = 0;
2148
2320
  async function worker() {
2149
2321
  while (true) {
2150
2322
  const i = index++;
2151
- if (i >= pathList.length) return;
2152
- await processPath(pathList[i], i);
2323
+ if (i >= puppeteerPaths.length) return;
2324
+ await processPath(puppeteerPaths[i], i, {
2325
+ onRawHtml: (seg, html) => {
2326
+ // Cache raw DOM snapshot for locale variant generation (NOT_FOUND_PATH excluded)
2327
+ if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
2328
+ },
2329
+ });
2153
2330
  }
2154
2331
  }
2155
2332
  await Promise.all(
2156
- Array.from({ length: Math.min(concurrency, pathList.length) }, () => worker())
2333
+ Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
2157
2334
  );
2158
2335
  } finally {
2159
2336
  await browser.close();
2160
2337
  }
2161
2338
 
2339
+ // Phase 2: Node.js — generate locale variants via text substitution
2340
+ if (localeVariantPaths.length > 0) {
2341
+ process.stdout.write(` Generating ${localeVariantPaths.length} locale variant(s) via text substitution...\n`);
2342
+ let substIndex = 0;
2343
+ for (const { pathSeg, basePathSeg, targetLocale } of localeVariantPaths) {
2344
+ substIndex++;
2345
+ const rawHtml = baseHtmlCache.get(basePathSeg);
2346
+ if (!rawHtml) {
2347
+ // Base path wasn't rendered — log and skip (shouldn't happen in normal builds)
2348
+ failedPaths.push({ path: '/' + pathSeg, message: `substitution skipped: base path "${basePathSeg || '/'}" not in cache` });
2349
+ process.stderr.write(`prerender: substitution skipped /${pathSeg} (base not found)\n`);
2350
+ continue;
2351
+ }
2352
+
2353
+ const displayPath = '/' + pathSeg;
2354
+ process.stdout.write(` [subst ${substIndex}/${localeVariantPaths.length}] ${displayPath}\n`);
2355
+
2356
+ try {
2357
+ const pairs = substitutionMaps.get(targetLocale) || [];
2358
+ const { html, utilityBlocks: pageBlocks } = generateLocaleVariantHtml({
2359
+ rawHtml, pathSeg, targetLocale, locales, defaultLocale,
2360
+ config, manifest, routerBasePath, tailwindBuilt, bundleUtilities,
2361
+ substitutionPairs: pairs,
2362
+ });
2363
+ for (const b of pageBlocks) utilityBlocks.push(b);
2364
+
2365
+ const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
2366
+ const outDir = join(config.output, ...fileSegments);
2367
+ mkdirSync(outDir, { recursive: true });
2368
+ writeFileSync(join(outDir, 'index.html'), html, 'utf8');
2369
+ } catch (err) {
2370
+ failedPaths.push({ path: displayPath, message: err?.message ?? String(err) });
2371
+ process.stderr.write(`prerender: substitution failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
2372
+ }
2373
+ }
2374
+ }
2375
+
2162
2376
  if (failedPaths.length > 0) {
2163
2377
  const sample = failedPaths.slice(0, 5).map((f) => `${f.path}: ${f.message}`).join(' | ');
2164
2378
  throw new Error(`prerender failed for ${failedPaths.length}/${pathTotal} paths. Sample: ${sample}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {