mnfst-render 0.4.0 → 0.4.2

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 +356 -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,83 @@ 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
+ // Two-pass categorisation: locale substitution only applies when the locale-neutral base path
1687
+ // (e.g. 'about' for 'fr/about') is itself in the path list and will be Puppeteer-rendered.
1688
+ //
1689
+ // Paths whose data is inherently locale-specific (e.g. 'en/articles/slug', 'fr/articles/slug'
1690
+ // discovered from per-locale data sources) have no locale-neutral counterpart and must be
1691
+ // rendered by Puppeteer directly — their content differs per locale and substitution cannot
1692
+ // produce correct output. This mirrors the framework's own data model: locale-neutral paths
1693
+ // use a shared structure with CSV text overlay; locale-prefixed paths carry per-locale content.
1694
+
1695
+ // Pass 1: collect all locale-neutral path segments (no locale prefix in the first segment).
1696
+ const localeNeutralPathSet = new Set();
1697
+ for (const seg of pathList) {
1698
+ if (!seg || seg === NOT_FOUND_PATH) continue;
1699
+ if (!localeSet.has(seg.split('/')[0])) localeNeutralPathSet.add(seg);
1700
+ }
1701
+
1702
+ // Pass 2: categorise.
1703
+ for (const seg of pathList) {
1704
+ if (!localeSubstEnabled || seg === NOT_FOUND_PATH || !seg) {
1705
+ puppeteerPaths.push(seg);
1706
+ continue;
1707
+ }
1708
+ const fp = seg.split('/')[0];
1709
+ if (!localeSet.has(fp) || localeSubstExclude.has(fp)) {
1710
+ puppeteerPaths.push(seg);
1711
+ continue;
1712
+ }
1713
+ const basePathSeg = seg.slice(fp.length + 1) || '';
1714
+ if (localeNeutralPathSet.has(basePathSeg)) {
1715
+ // Locale-neutral base exists and will be Puppeteer-rendered → safe to substitute.
1716
+ localeVariantPaths.push({ pathSeg: seg, basePathSeg, targetLocale: fp });
1717
+ } else {
1718
+ // No locale-neutral base — this path has per-locale content; Puppeteer required.
1719
+ puppeteerPaths.push(seg);
1720
+ }
1721
+ }
1722
+
1723
+ // Preload locale data for text substitution (all CSV sources with locale columns)
1724
+ const allLocaleData = loadAllLocaleContentData(manifest, config.root, locales);
1725
+ const substitutionMaps = new Map(); // locale → [[from, to], ...]
1726
+ for (const locale of locales) {
1727
+ if (locale === defaultLocale) {
1728
+ substitutionMaps.set(locale, []); // default locale: no text substitution needed
1729
+ } else {
1730
+ substitutionMaps.set(locale, buildSubstitutionPairs(
1731
+ allLocaleData.get(defaultLocale) || {},
1732
+ allLocaleData.get(locale) || {}
1733
+ ));
1734
+ }
1735
+ }
1736
+
1737
+ // baseHtmlCache: base path segment → raw DOM HTML captured before any Node.js transforms
1738
+ const baseHtmlCache = new Map();
1739
+ const puppeteerTotal = puppeteerPaths.length;
1740
+
1741
+ process.stdout.write(`Prerendering ${pathTotal} path(s) (${puppeteerTotal} via Puppeteer, ${localeVariantPaths.length} via substitution)...\n`);
1550
1742
 
1551
1743
  function pushDebug(row) {
1552
1744
  if (!config.debugPrerender) return;
1553
1745
  debugRows.push(row);
1554
1746
  }
1555
1747
 
1556
- async function processPath(pathSeg, pathIndex) {
1748
+ async function processPath(pathSeg, pathIndex, { onRawHtml } = {}) {
1557
1749
  const is404 = pathSeg === NOT_FOUND_PATH;
1558
1750
  const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
1559
1751
  const displayPath = pathSeg === '' ? '/' : pathname;
1560
- process.stdout.write(` [ ${pathIndex + 1}/${pathTotal} ] ${displayPath}\n`);
1752
+ process.stdout.write(` [ ${pathIndex + 1}/${puppeteerTotal} ] ${displayPath}\n`);
1561
1753
  const url = `${config.localUrl}${pathname}`;
1562
1754
  const fileSegments = is404 ? [] : pathToFileSegments(pathSeg ? `/${pathSeg}` : '/');
1563
1755
  const outDir = is404 ? config.output : join(config.output, ...fileSegments);
@@ -1646,16 +1838,16 @@ async function runPrerender(config) {
1646
1838
  });
1647
1839
  }).catch(() => { });
1648
1840
 
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, {
1841
+ // Set locale, dispatch route/locale events, call component swapping, then wait for
1842
+ // manifest:render-ready the single authoritative signal that all data sources have
1843
+ // settled for this locale/route. Falls back to timeout for older data plugins.
1844
+ await waitForManifestRenderReady(page, {
1652
1845
  allLocales: locales,
1653
1846
  currentLocale,
1654
1847
  timeoutMs: config.pipelineTimeout,
1655
1848
  });
1656
1849
 
1657
- await page.waitForNetworkIdle({ idleTime: 800, timeout: 12000 }).catch(() => { });
1658
- await waitForAlpineDataStoresSettled(page, 10000);
1850
+ // Flush any remaining Alpine microtask effects after the render-ready signal.
1659
1851
  await flushAlpineEffects(page);
1660
1852
 
1661
1853
  if (config.debugPrerender) {
@@ -2066,6 +2258,8 @@ async function runPrerender(config) {
2066
2258
  });
2067
2259
 
2068
2260
  let html = await page.evaluate(() => document.documentElement.outerHTML);
2261
+ // Cache raw DOM snapshot for locale variant generation (before any Node.js transforms).
2262
+ if (typeof onRawHtml === 'function') onRawHtml(pathSeg, html);
2069
2263
  if (config.debugPrerender) {
2070
2264
  const post = await page.evaluate(() => {
2071
2265
  const templates = document.querySelectorAll('template[x-for]').length;
@@ -2143,22 +2337,65 @@ async function runPrerender(config) {
2143
2337
  }
2144
2338
  }
2145
2339
 
2340
+ // Phase 1: Puppeteer — render base paths, cache raw DOM for substitution
2146
2341
  try {
2147
2342
  let index = 0;
2148
2343
  async function worker() {
2149
2344
  while (true) {
2150
2345
  const i = index++;
2151
- if (i >= pathList.length) return;
2152
- await processPath(pathList[i], i);
2346
+ if (i >= puppeteerPaths.length) return;
2347
+ await processPath(puppeteerPaths[i], i, {
2348
+ onRawHtml: (seg, html) => {
2349
+ // Cache raw DOM snapshot for locale variant generation (NOT_FOUND_PATH excluded)
2350
+ if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
2351
+ },
2352
+ });
2153
2353
  }
2154
2354
  }
2155
2355
  await Promise.all(
2156
- Array.from({ length: Math.min(concurrency, pathList.length) }, () => worker())
2356
+ Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
2157
2357
  );
2158
2358
  } finally {
2159
2359
  await browser.close();
2160
2360
  }
2161
2361
 
2362
+ // Phase 2: Node.js — generate locale variants via text substitution
2363
+ if (localeVariantPaths.length > 0) {
2364
+ process.stdout.write(` Generating ${localeVariantPaths.length} locale variant(s) via text substitution...\n`);
2365
+ let substIndex = 0;
2366
+ for (const { pathSeg, basePathSeg, targetLocale } of localeVariantPaths) {
2367
+ substIndex++;
2368
+ const rawHtml = baseHtmlCache.get(basePathSeg);
2369
+ if (!rawHtml) {
2370
+ // Base path was expected to be Puppeteer-rendered but is absent — its render likely failed.
2371
+ failedPaths.push({ path: '/' + pathSeg, message: `base path "${basePathSeg || '/'}" missing from cache (did its Puppeteer render fail?)` });
2372
+ process.stderr.write(`prerender: skipped /${pathSeg} — base "${basePathSeg || '/'}" not in cache\n`);
2373
+ continue;
2374
+ }
2375
+
2376
+ const displayPath = '/' + pathSeg;
2377
+ process.stdout.write(` [subst ${substIndex}/${localeVariantPaths.length}] ${displayPath}\n`);
2378
+
2379
+ try {
2380
+ const pairs = substitutionMaps.get(targetLocale) || [];
2381
+ const { html, utilityBlocks: pageBlocks } = generateLocaleVariantHtml({
2382
+ rawHtml, pathSeg, targetLocale, locales, defaultLocale,
2383
+ config, manifest, routerBasePath, tailwindBuilt, bundleUtilities,
2384
+ substitutionPairs: pairs,
2385
+ });
2386
+ for (const b of pageBlocks) utilityBlocks.push(b);
2387
+
2388
+ const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
2389
+ const outDir = join(config.output, ...fileSegments);
2390
+ mkdirSync(outDir, { recursive: true });
2391
+ writeFileSync(join(outDir, 'index.html'), html, 'utf8');
2392
+ } catch (err) {
2393
+ failedPaths.push({ path: displayPath, message: err?.message ?? String(err) });
2394
+ process.stderr.write(`prerender: substitution failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
2395
+ }
2396
+ }
2397
+ }
2398
+
2162
2399
  if (failedPaths.length > 0) {
2163
2400
  const sample = failedPaths.slice(0, 5).map((f) => `${f.path}: ${f.message}`).join(' | ');
2164
2401
  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.2",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {