mnfst-render 0.5.25 → 0.5.27
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 +249 -176
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -214,102 +214,18 @@ function parseArgs() {
|
|
|
214
214
|
function loadConfig(rootDir) {
|
|
215
215
|
const manifestPath = join(rootDir, 'manifest.json');
|
|
216
216
|
if (!existsSync(manifestPath)) {
|
|
217
|
-
return {
|
|
217
|
+
return { prerender: {} };
|
|
218
218
|
}
|
|
219
219
|
const raw = readFileSync(manifestPath, 'utf8');
|
|
220
220
|
let manifest;
|
|
221
221
|
try {
|
|
222
222
|
manifest = JSON.parse(raw);
|
|
223
223
|
} catch {
|
|
224
|
-
return {
|
|
224
|
+
return { prerender: {} };
|
|
225
225
|
}
|
|
226
226
|
return manifest;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
// Move credential-shaped values out of manifest.json into .env. Anything
|
|
230
|
-
// inlined into manifest.json ships verbatim into dist/ (the file is copied
|
|
231
|
-
// to the output unchanged), so a literal devKey there is a production leak.
|
|
232
|
-
// Rewriting source AND the in-memory object covers both directions: future
|
|
233
|
-
// renders see the placeholder, and the dist copy this run produces does too.
|
|
234
|
-
// Idempotent — values already shaped like ${VAR} are skipped.
|
|
235
|
-
function relocateSecretsToEnv(rootDir, manifest) {
|
|
236
|
-
const moves = [];
|
|
237
|
-
|
|
238
|
-
function maybeMove(obj, key, envVar, displayPath) {
|
|
239
|
-
if (!obj || typeof obj !== 'object') return;
|
|
240
|
-
const v = obj[key];
|
|
241
|
-
if (typeof v !== 'string' || !v) return;
|
|
242
|
-
if (/^\$\{[^}]+\}$/.test(v)) return; // already a placeholder
|
|
243
|
-
moves.push({ obj, key, value: v, envVar, displayPath });
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
maybeMove(manifest.appwrite, 'devKey', 'APPWRITE_DEV_KEY', 'appwrite.devKey');
|
|
247
|
-
if (manifest.data && typeof manifest.data === 'object') {
|
|
248
|
-
for (const [srcName, src] of Object.entries(manifest.data)) {
|
|
249
|
-
maybeMove(src, 'appwriteDevKey', 'APPWRITE_DEV_KEY', `data.${srcName}.appwriteDevKey`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (moves.length === 0) return manifest;
|
|
254
|
-
|
|
255
|
-
for (const m of moves) {
|
|
256
|
-
m.obj[m.key] = `\${${m.envVar}}`;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const manifestPath = join(rootDir, 'manifest.json');
|
|
260
|
-
try {
|
|
261
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + '\n');
|
|
262
|
-
} catch (err) {
|
|
263
|
-
console.error(`mnfst-render: failed to rewrite ${manifestPath}: ${err.message}`);
|
|
264
|
-
process.exit(1);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const envPath = join(rootDir, '.env');
|
|
268
|
-
let envText = '';
|
|
269
|
-
try {
|
|
270
|
-
if (existsSync(envPath)) envText = readFileSync(envPath, 'utf8');
|
|
271
|
-
} catch {}
|
|
272
|
-
if (envText && !envText.endsWith('\n')) envText += '\n';
|
|
273
|
-
|
|
274
|
-
const existingVars = new Set();
|
|
275
|
-
for (const line of envText.split(/\r?\n/)) {
|
|
276
|
-
const m = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
|
|
277
|
-
if (m) existingVars.add(m[1]);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const additions = [];
|
|
281
|
-
for (const m of moves) {
|
|
282
|
-
if (!existingVars.has(m.envVar)) {
|
|
283
|
-
additions.push(`${m.envVar}=${m.value}`);
|
|
284
|
-
existingVars.add(m.envVar);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (additions.length) {
|
|
289
|
-
try {
|
|
290
|
-
writeFileSync(envPath, envText + additions.join('\n') + '\n');
|
|
291
|
-
} catch (err) {
|
|
292
|
-
console.error(`mnfst-render: failed to write ${envPath}: ${err.message}`);
|
|
293
|
-
process.exit(1);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
console.warn('');
|
|
298
|
-
console.warn('mnfst-render: relocated credentials from manifest.json to .env');
|
|
299
|
-
for (const m of moves) {
|
|
300
|
-
console.warn(` • ${m.displayPath} → \${${m.envVar}}`);
|
|
301
|
-
}
|
|
302
|
-
if (additions.length) {
|
|
303
|
-
console.warn(` Appended ${additions.length} var(s) to .env (verify .env is in .gitignore).`);
|
|
304
|
-
} else {
|
|
305
|
-
console.warn(' .env already had matching vars; manifest.json placeholders now point at them.');
|
|
306
|
-
}
|
|
307
|
-
console.warn(' Browser-side dev needs window.env populated separately (e.g. env.local.js).');
|
|
308
|
-
console.warn('');
|
|
309
|
-
|
|
310
|
-
return manifest;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
229
|
function normalizeLocaleRouteExclude(val) {
|
|
314
230
|
if (val == null) return [];
|
|
315
231
|
if (Array.isArray(val)) return val.map((s) => String(s).trim()).filter(Boolean);
|
|
@@ -321,44 +237,45 @@ function resolveConfig() {
|
|
|
321
237
|
const cli = parseArgs();
|
|
322
238
|
const cwd = process.cwd();
|
|
323
239
|
const root = resolve(cwd, cli.root ?? '.');
|
|
324
|
-
const manifest =
|
|
325
|
-
const
|
|
240
|
+
const manifest = loadConfig(root);
|
|
241
|
+
const pre = manifest.prerender ?? {};
|
|
326
242
|
|
|
327
|
-
const localUrl = (cli.localUrl ?? cli.baseUrl ?? process.env.PRERENDER_BASE ??
|
|
243
|
+
const localUrl = (cli.localUrl ?? cli.baseUrl ?? process.env.PRERENDER_BASE ?? pre.localUrl ?? pre.baseUrl)?.replace(/\/$/, '');
|
|
328
244
|
const serve = cli.localUrl ? false : (cli.serve !== undefined ? !!cli.serve : true);
|
|
329
245
|
if (!serve && !localUrl) {
|
|
330
|
-
console.error('prerender: localUrl is required when not using built-in server. Set manifest.
|
|
246
|
+
console.error('prerender: localUrl is required when not using built-in server. Set manifest.prerender.localUrl or use --local.');
|
|
331
247
|
process.exit(1);
|
|
332
248
|
}
|
|
333
|
-
const liveUrl = (cli.liveUrl ?? process.env.PRERENDER_LIVE ?? manifest.live_url ?? manifest.liveUrl ??
|
|
249
|
+
const liveUrl = (cli.liveUrl ?? process.env.PRERENDER_LIVE ?? manifest.live_url ?? manifest.liveUrl ?? pre.live_url ?? pre.liveUrl ?? localUrl ?? '')?.replace(/\/$/, '');
|
|
334
250
|
|
|
335
251
|
return {
|
|
336
252
|
localUrl: localUrl ?? '',
|
|
337
253
|
liveUrl,
|
|
338
254
|
serve,
|
|
339
|
-
output: resolve(root, cli.output ??
|
|
255
|
+
output: resolve(root, cli.output ?? pre.output ?? 'website'),
|
|
340
256
|
root,
|
|
341
|
-
|
|
257
|
+
manifest,
|
|
258
|
+
routerBase: pre.routerBase ?? null,
|
|
342
259
|
/** Logical path prefixes (after locale) that skip sticky locale prefix; see manifest:locale-route-exclude */
|
|
343
260
|
localeRouteExclude: normalizeLocaleRouteExclude(
|
|
344
|
-
|
|
261
|
+
pre.localeRouteExclude ?? pre.localeStickyExclude
|
|
345
262
|
),
|
|
346
|
-
locales:
|
|
347
|
-
redirects: Array.isArray(
|
|
348
|
-
wait: cli.wait ??
|
|
263
|
+
locales: pre.locales,
|
|
264
|
+
redirects: Array.isArray(pre.redirects) ? pre.redirects : [],
|
|
265
|
+
wait: cli.wait ?? pre.wait ?? null,
|
|
349
266
|
waitAfterIdle: 0,
|
|
350
267
|
// Default concurrency: 2. Chromium per-page memory overhead is large and
|
|
351
268
|
// our hydration source-attribute map adds more per page. On big sites
|
|
352
269
|
// (>100 routes) higher concurrency crashes the browser with OOM/target
|
|
353
270
|
// closed errors. Users can override for small projects with --concurrency.
|
|
354
|
-
concurrency: Math.max(1, cli.concurrency ??
|
|
355
|
-
retries: Math.max(0, cli.retries ??
|
|
271
|
+
concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? 2),
|
|
272
|
+
retries: Math.max(0, cli.retries ?? pre.retries ?? 2),
|
|
356
273
|
localeSubstitution: true,
|
|
357
274
|
localeSubstitutionExclude: [],
|
|
358
275
|
/** Explicit locale-neutral paths to render in addition to those discovered automatically.
|
|
359
276
|
* Each entry is expanded to all locale variants (e.g. "legal/privacy" → "cs/legal/privacy", ...) */
|
|
360
|
-
paths: Array.isArray(
|
|
361
|
-
?
|
|
277
|
+
paths: Array.isArray(pre.paths)
|
|
278
|
+
? pre.paths.map((p) => String(p).replace(/^\/+|\/+$/g, '')).filter(Boolean)
|
|
362
279
|
: [],
|
|
363
280
|
dryRun: !!cli.dryRun,
|
|
364
281
|
debugPrerender: !!cli.debugPrerender,
|
|
@@ -367,12 +284,12 @@ function resolveConfig() {
|
|
|
367
284
|
// fall back to the timeout. 10s gives slow data plugin pipelines a
|
|
368
285
|
// chance while bounding worst-case per-path overhead.
|
|
369
286
|
pipelineTimeout: 10000,
|
|
370
|
-
// SEO / AEO meta injection — see metaInjection() and the
|
|
287
|
+
// SEO / AEO meta injection — see metaInjection() and the prerender.meta
|
|
371
288
|
// section of manifest.json. Layered precedence (highest first):
|
|
372
289
|
// 1. <template data-head> per-route (already in DOM at snapshot time)
|
|
373
290
|
// 2. <head> in index.html (already in DOM at snapshot time)
|
|
374
|
-
// 3.
|
|
375
|
-
// 4.
|
|
291
|
+
// 3. prerender.meta.* expressions (Alpine-evaluated per route)
|
|
292
|
+
// 4. prerender.meta.fallback.* (static strings if expression empty)
|
|
376
293
|
// 5. PWA-style manifest.json fields (name, description, author, icons)
|
|
377
294
|
// 6. Smart defaults derived from the rendered DOM (h1, first p, etc.)
|
|
378
295
|
//
|
|
@@ -383,10 +300,10 @@ function resolveConfig() {
|
|
|
383
300
|
siteDescription: manifest.description || null,
|
|
384
301
|
siteAuthor: manifest.author || null,
|
|
385
302
|
icons: Array.isArray(manifest.icons) ? manifest.icons : [],
|
|
386
|
-
meta:
|
|
387
|
-
structuredData:
|
|
388
|
-
imageSnapshots:
|
|
389
|
-
defaults:
|
|
303
|
+
meta: pre.meta || null,
|
|
304
|
+
structuredData: pre.structuredData || null,
|
|
305
|
+
imageSnapshots: pre.meta?.imageSnapshots !== false, // default true
|
|
306
|
+
defaults: pre.meta?.defaults !== false, // default true
|
|
390
307
|
},
|
|
391
308
|
};
|
|
392
309
|
}
|
|
@@ -808,6 +725,18 @@ function stripDataTailwindAttr(html) {
|
|
|
808
725
|
return html.replace(/\sdata-tailwind(?:=(["']).*?\1)?/gi, '');
|
|
809
726
|
}
|
|
810
727
|
|
|
728
|
+
/** Prepend `<!DOCTYPE html>` unless one is already present.
|
|
729
|
+
*
|
|
730
|
+
* The snapshot is captured via `document.documentElement.outerHTML`, which
|
|
731
|
+
* serializes only the <html> subtree and drops the document's doctype.
|
|
732
|
+
* Shipping that doctype-less HTML triggers quirks mode in browsers and is
|
|
733
|
+
* flagged by Lighthouse/PageSpeed. Re-add it at write time so every emitted
|
|
734
|
+
* page (Puppeteer-rendered base pages and substituted locale variants) is in
|
|
735
|
+
* standards mode. */
|
|
736
|
+
function ensureDoctype(html) {
|
|
737
|
+
return /^\s*<!doctype\b/i.test(html) ? html : `<!DOCTYPE html>\n${html}`;
|
|
738
|
+
}
|
|
739
|
+
|
|
811
740
|
/** Theme class de-bake + synchronous bootstrap.
|
|
812
741
|
*
|
|
813
742
|
* Puppeteer applies `<html class="light">` or `<html class="dark">` based on
|
|
@@ -853,18 +782,18 @@ function debakeThemeClass(html) {
|
|
|
853
782
|
return out;
|
|
854
783
|
}
|
|
855
784
|
|
|
856
|
-
/** Manifest utilities plugin: <style id="
|
|
785
|
+
/** Manifest utilities plugin: <style id="manifest-styles"> and <style id="manifest-styles-critical"> */
|
|
857
786
|
function extractUtilityStyleBlocks(html) {
|
|
858
787
|
const blocks = [];
|
|
859
788
|
let out = html.replace(
|
|
860
|
-
/<style[^>]*\bid=["']
|
|
789
|
+
/<style[^>]*\bid=["']manifest-styles-critical["'][^>]*>([\s\S]*?)<\/style>/gi,
|
|
861
790
|
(_, css) => {
|
|
862
791
|
const t = (css || '').trim();
|
|
863
792
|
if (t) blocks.push({ kind: 'critical', css: t });
|
|
864
793
|
return '';
|
|
865
794
|
}
|
|
866
795
|
);
|
|
867
|
-
out = out.replace(/<style[^>]*\bid=["']
|
|
796
|
+
out = out.replace(/<style[^>]*\bid=["']manifest-styles["'][^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
|
|
868
797
|
const t = (css || '').trim();
|
|
869
798
|
if (t) blocks.push({ kind: 'main', css: t });
|
|
870
799
|
return '';
|
|
@@ -926,9 +855,9 @@ function promptContinueWithRuntimeTailwind(rootDir) {
|
|
|
926
855
|
/**
|
|
927
856
|
* Build a static Tailwind stylesheet via @tailwindcss/cli (v4+), scanning project sources.
|
|
928
857
|
* Only runs when the project uses data-tailwind on the manifest script tag (auto-detected).
|
|
929
|
-
* Set manifest.
|
|
858
|
+
* Set manifest.prerender.tailwindInput to a custom CSS entry file if needed.
|
|
930
859
|
*/
|
|
931
|
-
function runTailwindCliForPrerender(rootDir, outputDir,
|
|
860
|
+
function runTailwindCliForPrerender(rootDir, outputDir, pre) {
|
|
932
861
|
if (!indexHtmlUsesTailwind(rootDir)) return false;
|
|
933
862
|
|
|
934
863
|
const outCss = join(outputDir, 'prerender.tailwind.css');
|
|
@@ -944,7 +873,7 @@ function runTailwindCliForPrerender(rootDir, outputDir, ren) {
|
|
|
944
873
|
}
|
|
945
874
|
let inputPath = null;
|
|
946
875
|
let createdTempInput = false;
|
|
947
|
-
const userInput =
|
|
876
|
+
const userInput = pre?.tailwindInput;
|
|
948
877
|
if (typeof userInput === 'string' && userInput.trim()) {
|
|
949
878
|
inputPath = resolve(rootDir, userInput.trim());
|
|
950
879
|
}
|
|
@@ -975,15 +904,10 @@ function runTailwindCliForPrerender(rootDir, outputDir, ren) {
|
|
|
975
904
|
}
|
|
976
905
|
|
|
977
906
|
process.stdout.write('prerender: compiling Tailwind CSS (this may take a minute)...\n');
|
|
978
|
-
|
|
979
|
-
// `shell: true`. With `shell: true`, every argument (including the user-
|
|
980
|
-
// controlled `tailwindInput` path from manifest.json) gets parsed by cmd.exe
|
|
981
|
-
// — so a value like `styles.css & evilcmd` would run `evilcmd`. Calling
|
|
982
|
-
// npx.cmd directly keeps args as a literal argv with no shell interpretation.
|
|
983
|
-
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
984
|
-
const r = spawnSync(command, args, {
|
|
907
|
+
const r = spawnSync('npx', args, {
|
|
985
908
|
cwd: rootDir,
|
|
986
909
|
encoding: 'utf8',
|
|
910
|
+
shell: process.platform === 'win32',
|
|
987
911
|
});
|
|
988
912
|
if (createdTempInput) {
|
|
989
913
|
try {
|
|
@@ -993,7 +917,7 @@ function runTailwindCliForPrerender(rootDir, outputDir, ren) {
|
|
|
993
917
|
}
|
|
994
918
|
}
|
|
995
919
|
if (r.status !== 0) {
|
|
996
|
-
console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or check tailwindInput in manifest.
|
|
920
|
+
console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or check tailwindInput in manifest.prerender.');
|
|
997
921
|
if (r.stderr) console.error(r.stderr);
|
|
998
922
|
if (r.stdout) console.error(r.stdout);
|
|
999
923
|
return false;
|
|
@@ -1299,7 +1223,8 @@ function stripPrerenderBakedRadioCheckedForXModel(html) {
|
|
|
1299
1223
|
|
|
1300
1224
|
// --- Canonical and hreflang (per-page injection) ---
|
|
1301
1225
|
|
|
1302
|
-
function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
|
|
1226
|
+
function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base, opts = {}) {
|
|
1227
|
+
const { skipCanonical = false } = opts;
|
|
1303
1228
|
const baseClean = base.replace(/\/$/, '');
|
|
1304
1229
|
const defaultLoc = defaultLocale || locales[0];
|
|
1305
1230
|
const isDefaultLocalePrefixed =
|
|
@@ -1312,7 +1237,10 @@ function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
|
|
|
1312
1237
|
: pathSeg;
|
|
1313
1238
|
const canonicalHref = canonicalPath === '' ? `${baseClean}/` : `${baseClean}/${canonicalPath}`;
|
|
1314
1239
|
const esc = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"');
|
|
1315
|
-
|
|
1240
|
+
// Skip the canonical <link> when the source head already declares one
|
|
1241
|
+
// (first-wins, per seo-aeo.md); hreflang alternates are still emitted since
|
|
1242
|
+
// those are framework-derived and rarely authored by hand.
|
|
1243
|
+
let out = skipCanonical ? '' : `<link rel="canonical" href="${esc(canonicalHref)}">\n`;
|
|
1316
1244
|
if (locales.length > 1) {
|
|
1317
1245
|
const currentLocale = locales.find((l) => pathSeg === l || pathSeg.startsWith(l + '/')) || defaultLoc;
|
|
1318
1246
|
const logicalRoute =
|
|
@@ -1670,7 +1598,8 @@ function generateLocaleVariantHtml({
|
|
|
1670
1598
|
html = rewriteHtmlAssetPaths(html, fileSegments.length);
|
|
1671
1599
|
|
|
1672
1600
|
const liveBase = config.liveUrl.replace(/\/$/, '');
|
|
1673
|
-
const
|
|
1601
|
+
const hasSourceCanonical = /<link\b[^>]*\brel=(["'])\s*canonical\s*\1/i.test(html);
|
|
1602
|
+
const canonicalHreflang = buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, liveBase, { skipCanonical: hasSourceCanonical });
|
|
1674
1603
|
const ogLocale = buildOgLocale(pathSeg, locales, defaultLocale);
|
|
1675
1604
|
const injectOgLocale = ogLocale && hasOtherOgMeta(html);
|
|
1676
1605
|
if (injectOgLocale) html = stripOgLocaleFromHead(html);
|
|
@@ -2472,29 +2401,123 @@ function routeHtmlPath(outputDir, pathSeg) {
|
|
|
2472
2401
|
}
|
|
2473
2402
|
|
|
2474
2403
|
/**
|
|
2475
|
-
*
|
|
2476
|
-
*
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
2404
|
+
* Collect filesystem paths for all local-file data sources declared in
|
|
2405
|
+
* `manifest.json` that are relevant to the given locale. Caller stats them.
|
|
2406
|
+
*
|
|
2407
|
+
* Skips remote sources (URLs, Appwrite databases / storage) since they have
|
|
2408
|
+
* no local mtime. Locale-keyed JSON/YAML sources include only the matching
|
|
2409
|
+
* locale's file. Multilingual CSVs (`locales` key) include every listed file
|
|
2410
|
+
* because any column edit can affect the routed page.
|
|
2411
|
+
*/
|
|
2412
|
+
function collectDataSourceFiles(manifest, rootDir, effectiveLocale) {
|
|
2413
|
+
const files = [];
|
|
2414
|
+
const data = manifest?.data;
|
|
2415
|
+
if (!data || typeof data !== 'object') return files;
|
|
2416
|
+
|
|
2417
|
+
const isLocaleKey = (key) =>
|
|
2418
|
+
/^[a-z]{2,3}(?:-[A-Z][a-zA-Z]{1,7})?$/.test(key);
|
|
2419
|
+
|
|
2420
|
+
const localeMatches = (key) => {
|
|
2421
|
+
if (!isLocaleKey(key)) return false;
|
|
2422
|
+
if (effectiveLocale) return key === effectiveLocale;
|
|
2423
|
+
// No locale context: include any locale-shaped key.
|
|
2424
|
+
return true;
|
|
2425
|
+
};
|
|
2426
|
+
|
|
2427
|
+
const isLocalPath = (s) => typeof s === 'string' && !/^https?:\/\//i.test(s);
|
|
2428
|
+
const toAbs = (p) => join(rootDir, p.replace(/^\//, ''));
|
|
2429
|
+
|
|
2430
|
+
for (const value of Object.values(data)) {
|
|
2431
|
+
// Plain string path → single locale-agnostic file.
|
|
2432
|
+
if (isLocalPath(value)) {
|
|
2433
|
+
files.push(toAbs(value));
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
if (!value || typeof value !== 'object') continue;
|
|
2437
|
+
// Skip cloud / remote sources — they have no local file to stat.
|
|
2438
|
+
if (value.url || value.appwriteDatabaseId || value.appwriteTableId || value.appwriteBucketId) continue;
|
|
2439
|
+
|
|
2440
|
+
for (const [key, v] of Object.entries(value)) {
|
|
2441
|
+
// Multilingual CSV: { locales: "/p.csv" } or { locales: ["/a.csv", "/b.csv"] }
|
|
2442
|
+
if (key === 'locales') {
|
|
2443
|
+
if (isLocalPath(v)) files.push(toAbs(v));
|
|
2444
|
+
else if (Array.isArray(v)) {
|
|
2445
|
+
for (const p of v) if (isLocalPath(p)) files.push(toAbs(p));
|
|
2446
|
+
}
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2449
|
+
// Colorpicker palette: { colorpicker: "/p.yaml" } or { colorpicker: { en: ..., fr: ... } }
|
|
2450
|
+
if (key === 'colorpicker') {
|
|
2451
|
+
if (isLocalPath(v)) files.push(toAbs(v));
|
|
2452
|
+
else if (v && typeof v === 'object') {
|
|
2453
|
+
for (const [k, p] of Object.entries(v)) {
|
|
2454
|
+
if (localeMatches(k) && isLocalPath(p)) files.push(toAbs(p));
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
continue;
|
|
2458
|
+
}
|
|
2459
|
+
// Locale-keyed JSON/YAML: { en: "/p.en.json", fr: "/p.fr.json" }
|
|
2460
|
+
if (localeMatches(key) && isLocalPath(v)) {
|
|
2461
|
+
files.push(toAbs(v));
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
return files;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Best-effort per-route lastmod date. Takes the most recent mtime across:
|
|
2471
|
+
* 1. Backing source-file conventions (markdown under articles/, pages/<path>.html)
|
|
2472
|
+
* so direct content edits are reflected.
|
|
2473
|
+
* 2. Data-source files registered in `manifest.json` that are relevant to the
|
|
2474
|
+
* route's locale, so changes to JSON/YAML/CSV content driving the page
|
|
2475
|
+
* bump the date too (important for translated sites).
|
|
2476
|
+
*
|
|
2477
|
+
* Falls back to the prerendered HTML's own mtime only when nothing else is
|
|
2478
|
+
* statable — the HTML mtime reflects rebuild time rather than content change
|
|
2479
|
+
* time, so we prefer source/data mtimes when any exist.
|
|
2479
2480
|
*/
|
|
2480
|
-
function routeLastModDate(rootDir, outputDir, pathSeg) {
|
|
2481
|
-
//
|
|
2482
|
-
//
|
|
2483
|
-
//
|
|
2484
|
-
|
|
2485
|
-
|
|
2481
|
+
function routeLastModDate(rootDir, outputDir, pathSeg, manifest, localeList, defaultLocale) {
|
|
2482
|
+
// Detect a locale prefix on the path (e.g. "fr/about" → locale "fr",
|
|
2483
|
+
// unlocalized "about"). For unprefixed paths in a multi-locale site we
|
|
2484
|
+
// fall back to the default locale when matching data-source locale keys.
|
|
2485
|
+
let locale = null;
|
|
2486
|
+
let unlocalizedPath = pathSeg;
|
|
2487
|
+
if (Array.isArray(localeList) && localeList.length) {
|
|
2488
|
+
const first = pathSeg.split('/')[0];
|
|
2489
|
+
if (localeList.includes(first)) {
|
|
2490
|
+
locale = first;
|
|
2491
|
+
unlocalizedPath = pathSeg.slice(first.length + 1);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
const effectiveLocale = locale || defaultLocale || null;
|
|
2495
|
+
|
|
2496
|
+
// Source-file candidates from common conventions, keyed on the unlocalized
|
|
2497
|
+
// path (markdown files typically aren't per-locale duplicates).
|
|
2498
|
+
const stripPrefix = unlocalizedPath.replace(/^(?:docs|blog|articles|posts|guides)\//, '');
|
|
2486
2499
|
const candidates = [
|
|
2487
2500
|
join(rootDir, 'articles', `${stripPrefix}.md`),
|
|
2488
|
-
join(rootDir, 'articles', `${
|
|
2489
|
-
join(rootDir, 'pages', `${
|
|
2490
|
-
join(rootDir, `${
|
|
2501
|
+
join(rootDir, 'articles', `${unlocalizedPath}.md`),
|
|
2502
|
+
join(rootDir, 'pages', `${unlocalizedPath}.html`),
|
|
2503
|
+
join(rootDir, `${unlocalizedPath}.md`),
|
|
2491
2504
|
];
|
|
2505
|
+
|
|
2506
|
+
// Add data-source files relevant to this locale.
|
|
2507
|
+
if (manifest) {
|
|
2508
|
+
candidates.push(...collectDataSourceFiles(manifest, rootDir, effectiveLocale));
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// Take the max mtime across all source / data candidates.
|
|
2512
|
+
let latest = null;
|
|
2492
2513
|
for (const c of candidates) {
|
|
2493
2514
|
try {
|
|
2494
2515
|
const s = statSync(c);
|
|
2495
|
-
if (s.isFile()
|
|
2516
|
+
if (s.isFile() && (!latest || s.mtime > latest)) latest = s.mtime;
|
|
2496
2517
|
} catch { /* not found */ }
|
|
2497
2518
|
}
|
|
2519
|
+
if (latest) return latest.toISOString().slice(0, 10);
|
|
2520
|
+
|
|
2498
2521
|
// Fallback to the prerendered output mtime (always present).
|
|
2499
2522
|
try {
|
|
2500
2523
|
const out = routeHtmlPath(outputDir, pathSeg || '');
|
|
@@ -2509,6 +2532,7 @@ function writeSeoFiles(outputDir, pathList, liveUrl, locales, defaultLocale, ctx
|
|
|
2509
2532
|
const localeList = Array.isArray(locales) ? locales : [];
|
|
2510
2533
|
const multiLocale = localeList.length > 1;
|
|
2511
2534
|
const rootDir = ctx.rootDir || '';
|
|
2535
|
+
const manifest = ctx.manifest || null;
|
|
2512
2536
|
|
|
2513
2537
|
writeFileSync(
|
|
2514
2538
|
join(outputDir, 'robots.txt'),
|
|
@@ -2534,7 +2558,7 @@ Sitemap: ${base}/sitemap.xml
|
|
|
2534
2558
|
body += `\n <xhtml:link rel="alternate" hreflang="${escapeXmlText(hreflang)}" href="${escapeXmlText(href)}" />`;
|
|
2535
2559
|
}
|
|
2536
2560
|
}
|
|
2537
|
-
const lastmod = routeLastModDate(rootDir, outputDir, pathSeg);
|
|
2561
|
+
const lastmod = routeLastModDate(rootDir, outputDir, pathSeg, manifest, localeList, defaultLocale);
|
|
2538
2562
|
body += `\n <lastmod>${lastmod}</lastmod>
|
|
2539
2563
|
<changefreq>monthly</changefreq>
|
|
2540
2564
|
<priority>${path === '' ? '1.0' : '0.8'}</priority>`;
|
|
@@ -2805,6 +2829,42 @@ function copyProjectIntoDist(rootResolved, outputResolved) {
|
|
|
2805
2829
|
COPY_EXCLUDE.delete(outputDirName);
|
|
2806
2830
|
}
|
|
2807
2831
|
|
|
2832
|
+
/** Remove bare `@import "tailwindcss"` (and `tailwindcss/*` sub-imports) from
|
|
2833
|
+
* CSS files copied into the output.
|
|
2834
|
+
*
|
|
2835
|
+
* Tailwind v4 conventions put `@import "tailwindcss";` at the top of a
|
|
2836
|
+
* project's main CSS so the build tool pulls in the framework. When that same
|
|
2837
|
+
* file is also linked directly in the browser (as Manifest's
|
|
2838
|
+
* `manifest.theme.css` convention does), the browser's native CSS loader
|
|
2839
|
+
* resolves the bare specifier against the page origin and fetches
|
|
2840
|
+
* `/tailwindcss` — which 404s to the SPA shell (text/html), tripping
|
|
2841
|
+
* "Refused to apply style … is not a supported stylesheet MIME type" (flagged
|
|
2842
|
+
* by PageSpeed Best Practices). Manifest supplies Tailwind its own way
|
|
2843
|
+
* (compiled `prerender.tailwind.css` for MPA, the Play-CDN style engine for
|
|
2844
|
+
* SPA), so a raw import in a browser-served stylesheet is always redundant and
|
|
2845
|
+
* harmful. Strip it from the emitted copies only; source files are untouched. */
|
|
2846
|
+
function stripTailwindCssImportsFromOutput(outputDir) {
|
|
2847
|
+
const importRx = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']\s*\)?[^;\n]*;?[ \t]*\r?\n?/gi;
|
|
2848
|
+
const walk = (dir) => {
|
|
2849
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
2850
|
+
if (ent.name.startsWith('.')) continue;
|
|
2851
|
+
const p = join(dir, ent.name);
|
|
2852
|
+
if (ent.isDirectory()) {
|
|
2853
|
+
if (ent.name === 'node_modules') continue;
|
|
2854
|
+
walk(p);
|
|
2855
|
+
} else if (ent.name.endsWith('.css') && ent.name !== 'prerender.tailwind.css') {
|
|
2856
|
+
try {
|
|
2857
|
+
const css = readFileSync(p, 'utf8');
|
|
2858
|
+
if (!/tailwindcss/i.test(css)) continue;
|
|
2859
|
+
const next = css.replace(importRx, '');
|
|
2860
|
+
if (next !== css) writeFileSync(p, next, 'utf8');
|
|
2861
|
+
} catch { /* unreadable file — skip */ }
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
walk(outputDir);
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2808
2868
|
// --- Main --------------------------------------------------------------------
|
|
2809
2869
|
|
|
2810
2870
|
async function main() {
|
|
@@ -2847,7 +2907,7 @@ async function runPrerender(config) {
|
|
|
2847
2907
|
|
|
2848
2908
|
const defaultLocale = locales[0] ?? null;
|
|
2849
2909
|
const routeSegments = discoverRoutes(manifest, config.root);
|
|
2850
|
-
// Merge any explicitly configured paths (manifest.
|
|
2910
|
+
// Merge any explicitly configured paths (manifest.prerender.paths) into the discovered segments.
|
|
2851
2911
|
// These are treated as locale-neutral and get full locale-expansion like all other discovered paths.
|
|
2852
2912
|
if (config.paths && config.paths.length > 0) {
|
|
2853
2913
|
const segSet = new Set(routeSegments);
|
|
@@ -2894,7 +2954,7 @@ async function runPrerender(config) {
|
|
|
2894
2954
|
const outputResolved = resolve(config.output);
|
|
2895
2955
|
const rootResolved = resolve(config.root);
|
|
2896
2956
|
// Router base = URL pathname to the app root. When dist is deployed as site root (e.g. Appwrite), use "".
|
|
2897
|
-
// Set manifest.
|
|
2957
|
+
// Set manifest.prerender.routerBase only when the app is served from a subpath (e.g. /app).
|
|
2898
2958
|
let routerBasePath = null;
|
|
2899
2959
|
if (config.routerBase != null && String(config.routerBase).trim() !== '') {
|
|
2900
2960
|
const trimmed = String(config.routerBase).replace(/^\/+|\/+$/g, '').trim();
|
|
@@ -2908,10 +2968,18 @@ async function runPrerender(config) {
|
|
|
2908
2968
|
}
|
|
2909
2969
|
mkdirSync(outputResolved, { recursive: true });
|
|
2910
2970
|
copyProjectIntoDist(rootResolved, outputResolved);
|
|
2971
|
+
// Projects that use data-tailwind get their Tailwind from Manifest (compiled
|
|
2972
|
+
// prerender.tailwind.css below, or the runtime style engine). A leftover
|
|
2973
|
+
// `@import "tailwindcss"` in a browser-linked stylesheet (e.g. the
|
|
2974
|
+
// manifest.theme.css convention) would make the browser fetch /tailwindcss
|
|
2975
|
+
// and fail; strip those imports from the copied CSS.
|
|
2976
|
+
if (indexHtmlUsesTailwind(rootResolved)) {
|
|
2977
|
+
stripTailwindCssImportsFromOutput(outputResolved);
|
|
2978
|
+
}
|
|
2911
2979
|
|
|
2912
|
-
const
|
|
2913
|
-
const bundleUtilities =
|
|
2914
|
-
const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved,
|
|
2980
|
+
const pre = manifest.prerender ?? {};
|
|
2981
|
+
const bundleUtilities = pre.utilitiesBundle !== false;
|
|
2982
|
+
const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved, pre);
|
|
2915
2983
|
const utilityBlocks = [];
|
|
2916
2984
|
|
|
2917
2985
|
// Launch a fresh browser instance. Chromium is known to accumulate memory
|
|
@@ -2943,18 +3011,12 @@ async function runPrerender(config) {
|
|
|
2943
3011
|
console.error(' npm i -D puppeteer-core @sparticuz/chromium');
|
|
2944
3012
|
process.exit(1);
|
|
2945
3013
|
}
|
|
2946
|
-
// Chrome's sandbox is the primary defense against renderer-process
|
|
2947
|
-
// exploits — disabling it means any RCE in a rendered page runs as the
|
|
2948
|
-
// developer's UID with full filesystem access. We render arbitrary CDN
|
|
2949
|
-
// scripts and third-party iframes, so the threat is real. Opt-in only
|
|
2950
|
-
// for CI environments where the sandbox legitimately can't initialize:
|
|
2951
|
-
// set MNFST_RENDER_NO_SANDBOX=1 to add the flags back.
|
|
2952
|
-
const extraArgs = process.env.MNFST_RENDER_NO_SANDBOX === '1'
|
|
2953
|
-
? ['--no-sandbox', '--disable-setuid-sandbox']
|
|
2954
|
-
: [];
|
|
2955
3014
|
return await puppeteer.default.launch({
|
|
2956
3015
|
headless: true,
|
|
2957
|
-
args:
|
|
3016
|
+
args: [
|
|
3017
|
+
'--no-sandbox',
|
|
3018
|
+
'--disable-setuid-sandbox',
|
|
3019
|
+
],
|
|
2958
3020
|
});
|
|
2959
3021
|
}
|
|
2960
3022
|
}
|
|
@@ -2965,12 +3027,12 @@ async function runPrerender(config) {
|
|
|
2965
3027
|
// substantial, and we also now maintain a per-page source-attribute Map for
|
|
2966
3028
|
// the hydration contract. On large sites (>100 routes) higher concurrency
|
|
2967
3029
|
// spikes memory and crashes the browser. Users can still override via
|
|
2968
|
-
// --concurrency or manifest.
|
|
3030
|
+
// --concurrency or manifest.prerender.concurrency.
|
|
2969
3031
|
const concurrency = config.concurrency;
|
|
2970
3032
|
const maxRetries = config.retries ?? 2;
|
|
2971
3033
|
// Recycle the browser every N processed pages to bound resource growth.
|
|
2972
|
-
// Configurable via manifest.
|
|
2973
|
-
const browserRecycleEvery = Math.max(0,
|
|
3034
|
+
// Configurable via manifest.prerender.browserRecycleEvery.
|
|
3035
|
+
const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
|
|
2974
3036
|
let pagesSinceRecycle = 0;
|
|
2975
3037
|
const recycleLock = { busy: false };
|
|
2976
3038
|
// Workers block on this promise before touching `browser`. While a recycle
|
|
@@ -3848,11 +3910,20 @@ async function runPrerender(config) {
|
|
|
3848
3910
|
await page.evaluate(() => {
|
|
3849
3911
|
const A = window.Alpine;
|
|
3850
3912
|
const runBatch = typeof A?.mutateDom === 'function' ? (fn) => A.mutateDom(fn) : (fn) => fn();
|
|
3851
|
-
|
|
3913
|
+
// Collect every loop-scope identifier from the x-for LHS, including
|
|
3914
|
+
// destructuring forms — `item`, `(item, index)`, `[key, val]`,
|
|
3915
|
+
// `{ a, b }`. The old single/paren-only regex skipped destructured
|
|
3916
|
+
// loops entirely, leaving bindings like x-text="file?.label" on baked
|
|
3917
|
+
// clones; at runtime Alpine evaluated them outside the iteration scope
|
|
3918
|
+
// and threw "file is not defined".
|
|
3919
|
+
const extractLoopVars = (xForExpr) => {
|
|
3920
|
+
const m = String(xForExpr || '').match(/^([\s\S]*?)\s+(?:in|of)\s+/);
|
|
3921
|
+
return m ? (m[1].match(/[A-Za-z_$][\w$]*/g) || []) : [];
|
|
3922
|
+
};
|
|
3852
3923
|
// Include x-init: expanded clones still had x-init="getDescription(article)" etc.; Alpine then throws (article undefined).
|
|
3853
3924
|
const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-init|x-icon|x-on:|@)/;
|
|
3854
3925
|
const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
|
|
3855
|
-
const stripLoopBindings = (el,
|
|
3926
|
+
const stripLoopBindings = (el, loopVars) => {
|
|
3856
3927
|
const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
|
|
3857
3928
|
for (const node of nodes) {
|
|
3858
3929
|
// Skip elements inside data-hydrate islands — their bindings must remain live
|
|
@@ -3861,7 +3932,7 @@ async function runPrerender(config) {
|
|
|
3861
3932
|
for (const attr of attrs) {
|
|
3862
3933
|
if (!bindingAttrRegex.test(attr.name)) continue;
|
|
3863
3934
|
const expr = attr.value || '';
|
|
3864
|
-
if (
|
|
3935
|
+
if (loopVars.some((v) => hasVar(expr, v))) {
|
|
3865
3936
|
const name = attr.name;
|
|
3866
3937
|
if (name === 'x-text' || name === 'x-html') {
|
|
3867
3938
|
if ((node.textContent || '').trim() || (node.innerHTML || '').trim()) {
|
|
@@ -3903,10 +3974,8 @@ async function runPrerender(config) {
|
|
|
3903
3974
|
document.querySelectorAll('template[x-for]').forEach((tpl) => {
|
|
3904
3975
|
if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
|
|
3905
3976
|
const xFor = (tpl.getAttribute('x-for') || '').trim();
|
|
3906
|
-
const
|
|
3907
|
-
|
|
3908
|
-
const indexVar = m ? (m[2] || '') : '';
|
|
3909
|
-
if (!itemVar && !indexVar) return;
|
|
3977
|
+
const loopVars = extractLoopVars(xFor);
|
|
3978
|
+
if (!loopVars.length) return;
|
|
3910
3979
|
|
|
3911
3980
|
const first = tpl.content?.firstElementChild;
|
|
3912
3981
|
if (!first) return;
|
|
@@ -3915,7 +3984,7 @@ async function runPrerender(config) {
|
|
|
3915
3984
|
let next = tpl.nextElementSibling;
|
|
3916
3985
|
while (next) {
|
|
3917
3986
|
if (next.tagName !== tag) break;
|
|
3918
|
-
stripLoopBindings(next,
|
|
3987
|
+
stripLoopBindings(next, loopVars);
|
|
3919
3988
|
next = next.nextElementSibling;
|
|
3920
3989
|
}
|
|
3921
3990
|
});
|
|
@@ -4009,10 +4078,13 @@ async function runPrerender(config) {
|
|
|
4009
4078
|
// Remove orphan x-for clones that still reference loop-scope vars (e.g. image/index)
|
|
4010
4079
|
// outside their template scope. These throw Alpine errors in live static hosting.
|
|
4011
4080
|
await page.evaluate(() => {
|
|
4012
|
-
const
|
|
4081
|
+
const extractLoopVars = (xForExpr) => {
|
|
4082
|
+
const m = String(xForExpr || '').match(/^([\s\S]*?)\s+(?:in|of)\s+/);
|
|
4083
|
+
return m ? (m[1].match(/[A-Za-z_$][\w$]*/g) || []) : [];
|
|
4084
|
+
};
|
|
4013
4085
|
const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-init|x-icon|x-on:|@)/;
|
|
4014
4086
|
const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
|
|
4015
|
-
const elementReferencesLoopScope = (el,
|
|
4087
|
+
const elementReferencesLoopScope = (el, loopVars) => {
|
|
4016
4088
|
if (!el) return false;
|
|
4017
4089
|
const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
|
|
4018
4090
|
for (const node of nodes) {
|
|
@@ -4020,7 +4092,7 @@ async function runPrerender(config) {
|
|
|
4020
4092
|
for (const attr of attrs) {
|
|
4021
4093
|
if (!bindingAttrRegex.test(attr.name)) continue;
|
|
4022
4094
|
const expr = attr.value || '';
|
|
4023
|
-
if (
|
|
4095
|
+
if (loopVars.some((v) => hasVar(expr, v))) return true;
|
|
4024
4096
|
}
|
|
4025
4097
|
}
|
|
4026
4098
|
return false;
|
|
@@ -4030,10 +4102,8 @@ async function runPrerender(config) {
|
|
|
4030
4102
|
// Running this on all x-for templates can remove valid prerendered list items.
|
|
4031
4103
|
document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
|
|
4032
4104
|
const xFor = (tpl.getAttribute('x-for') || '').trim();
|
|
4033
|
-
const
|
|
4034
|
-
|
|
4035
|
-
const indexVar = m ? (m[2] || '') : '';
|
|
4036
|
-
if (!itemVar && !indexVar) return;
|
|
4105
|
+
const loopVars = extractLoopVars(xFor);
|
|
4106
|
+
if (!loopVars.length) return;
|
|
4037
4107
|
|
|
4038
4108
|
const first = tpl.content?.firstElementChild;
|
|
4039
4109
|
if (!first) return;
|
|
@@ -4044,7 +4114,7 @@ async function runPrerender(config) {
|
|
|
4044
4114
|
const sameTag = next.tagName === tag;
|
|
4045
4115
|
if (!sameTag) break;
|
|
4046
4116
|
|
|
4047
|
-
const referencesLoopScope = elementReferencesLoopScope(next,
|
|
4117
|
+
const referencesLoopScope = elementReferencesLoopScope(next, loopVars);
|
|
4048
4118
|
|
|
4049
4119
|
const toRemove = next;
|
|
4050
4120
|
next = next.nextElementSibling;
|
|
@@ -4167,7 +4237,8 @@ async function runPrerender(config) {
|
|
|
4167
4237
|
|
|
4168
4238
|
html = rewriteHtmlAssetPaths(html, fileSegments.length);
|
|
4169
4239
|
const liveBase = config.liveUrl.replace(/\/$/, '');
|
|
4170
|
-
const
|
|
4240
|
+
const hasSourceCanonical = /<link\b[^>]*\brel=(["'])\s*canonical\s*\1/i.test(html);
|
|
4241
|
+
const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase, { skipCanonical: hasSourceCanonical });
|
|
4171
4242
|
const ogLocale = buildOgLocale(is404 ? '' : pathSeg, locales, defaultLocale);
|
|
4172
4243
|
const injectOgLocale = ogLocale && hasOtherOgMeta(html);
|
|
4173
4244
|
if (injectOgLocale) html = stripOgLocaleFromHead(html);
|
|
@@ -4189,6 +4260,7 @@ async function runPrerender(config) {
|
|
|
4189
4260
|
);
|
|
4190
4261
|
// (Hydration contract was already injected into the raw HTML before
|
|
4191
4262
|
// the Node.js post-processing pipeline ran, so it's already present.)
|
|
4263
|
+
html = ensureDoctype(html);
|
|
4192
4264
|
mkdirSync(outDir, { recursive: true });
|
|
4193
4265
|
writeFileSync(outFile, html, 'utf8');
|
|
4194
4266
|
pushDebug({
|
|
@@ -4371,7 +4443,7 @@ async function runPrerender(config) {
|
|
|
4371
4443
|
const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
|
|
4372
4444
|
const outDir = join(config.output, ...fileSegments);
|
|
4373
4445
|
mkdirSync(outDir, { recursive: true });
|
|
4374
|
-
writeFileSync(join(outDir, 'index.html'), html, 'utf8');
|
|
4446
|
+
writeFileSync(join(outDir, 'index.html'), ensureDoctype(html), 'utf8');
|
|
4375
4447
|
} catch (err) {
|
|
4376
4448
|
failedPaths.push({ path: displayPath, message: err?.message ?? String(err) });
|
|
4377
4449
|
process.stderr.write(`prerender: substitution failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
|
|
@@ -4412,6 +4484,7 @@ async function runPrerender(config) {
|
|
|
4412
4484
|
defaultLocale,
|
|
4413
4485
|
{
|
|
4414
4486
|
rootDir: config.root,
|
|
4487
|
+
manifest: config.manifest,
|
|
4415
4488
|
siteName: config.seo?.siteName,
|
|
4416
4489
|
siteDescription: config.seo?.siteDescription,
|
|
4417
4490
|
}
|