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.
- package/manifest.render.mjs +356 -119
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* manifest:
|
|
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
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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:
|
|
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 ??
|
|
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, '"')}">\n`
|
|
1283
|
+
: '';
|
|
1284
|
+
const baseMeta = routerBasePath !== null
|
|
1285
|
+
? `<meta name="manifest:router-base" content="${String(routerBasePath).replace(/"/g, '"')}">\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
|
-
|
|
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}/${
|
|
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
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
|
|
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
|
-
|
|
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 >=
|
|
2152
|
-
await processPath(
|
|
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,
|
|
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}`);
|