mnfst-render 0.3.9 → 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.
- package/manifest.render.mjs +334 -120
- 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) {
|
|
@@ -1400,7 +1527,7 @@ function startStaticServer(rootDir) {
|
|
|
1400
1527
|
// --- Copy project into output so website is self-contained (e.g. for Appwrite). ---
|
|
1401
1528
|
const COPY_EXCLUDE = new Set([
|
|
1402
1529
|
'node_modules', '.git', 'package.json', 'package-lock.json',
|
|
1403
|
-
'index.html', 'prerender.mjs', 'prerender.js',
|
|
1530
|
+
'index.html', 'prerender.mjs', 'prerender.js', '_redirects',
|
|
1404
1531
|
]);
|
|
1405
1532
|
|
|
1406
1533
|
function copyProjectIntoDist(rootResolved, outputResolved) {
|
|
@@ -1546,18 +1673,60 @@ 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
|
+
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}/${
|
|
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
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
|
|
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
|
-
|
|
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 >=
|
|
2152
|
-
await processPath(
|
|
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,
|
|
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}`);
|