mnfst-render 0.5.26 → 0.5.28
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 +255 -38
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -238,7 +238,10 @@ function resolveConfig() {
|
|
|
238
238
|
const cwd = process.cwd();
|
|
239
239
|
const root = resolve(cwd, cli.root ?? '.');
|
|
240
240
|
const manifest = loadConfig(root);
|
|
241
|
-
|
|
241
|
+
// Render config lives under manifest.render (per schema + docs). Older
|
|
242
|
+
// projects used manifest.prerender — keep reading it as a fallback so they
|
|
243
|
+
// don't silently lose their config.
|
|
244
|
+
const pre = manifest.render ?? manifest.prerender ?? {};
|
|
242
245
|
|
|
243
246
|
const localUrl = (cli.localUrl ?? cli.baseUrl ?? process.env.PRERENDER_BASE ?? pre.localUrl ?? pre.baseUrl)?.replace(/\/$/, '');
|
|
244
247
|
const serve = cli.localUrl ? false : (cli.serve !== undefined ? !!cli.serve : true);
|
|
@@ -254,6 +257,7 @@ function resolveConfig() {
|
|
|
254
257
|
serve,
|
|
255
258
|
output: resolve(root, cli.output ?? pre.output ?? 'website'),
|
|
256
259
|
root,
|
|
260
|
+
manifest,
|
|
257
261
|
routerBase: pre.routerBase ?? null,
|
|
258
262
|
/** Logical path prefixes (after locale) that skip sticky locale prefix; see manifest:locale-route-exclude */
|
|
259
263
|
localeRouteExclude: normalizeLocaleRouteExclude(
|
|
@@ -724,6 +728,18 @@ function stripDataTailwindAttr(html) {
|
|
|
724
728
|
return html.replace(/\sdata-tailwind(?:=(["']).*?\1)?/gi, '');
|
|
725
729
|
}
|
|
726
730
|
|
|
731
|
+
/** Prepend `<!DOCTYPE html>` unless one is already present.
|
|
732
|
+
*
|
|
733
|
+
* The snapshot is captured via `document.documentElement.outerHTML`, which
|
|
734
|
+
* serializes only the <html> subtree and drops the document's doctype.
|
|
735
|
+
* Shipping that doctype-less HTML triggers quirks mode in browsers and is
|
|
736
|
+
* flagged by Lighthouse/PageSpeed. Re-add it at write time so every emitted
|
|
737
|
+
* page (Puppeteer-rendered base pages and substituted locale variants) is in
|
|
738
|
+
* standards mode. */
|
|
739
|
+
function ensureDoctype(html) {
|
|
740
|
+
return /^\s*<!doctype\b/i.test(html) ? html : `<!DOCTYPE html>\n${html}`;
|
|
741
|
+
}
|
|
742
|
+
|
|
727
743
|
/** Theme class de-bake + synchronous bootstrap.
|
|
728
744
|
*
|
|
729
745
|
* Puppeteer applies `<html class="light">` or `<html class="dark">` based on
|
|
@@ -1210,7 +1226,8 @@ function stripPrerenderBakedRadioCheckedForXModel(html) {
|
|
|
1210
1226
|
|
|
1211
1227
|
// --- Canonical and hreflang (per-page injection) ---
|
|
1212
1228
|
|
|
1213
|
-
function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
|
|
1229
|
+
function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base, opts = {}) {
|
|
1230
|
+
const { skipCanonical = false } = opts;
|
|
1214
1231
|
const baseClean = base.replace(/\/$/, '');
|
|
1215
1232
|
const defaultLoc = defaultLocale || locales[0];
|
|
1216
1233
|
const isDefaultLocalePrefixed =
|
|
@@ -1223,7 +1240,10 @@ function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
|
|
|
1223
1240
|
: pathSeg;
|
|
1224
1241
|
const canonicalHref = canonicalPath === '' ? `${baseClean}/` : `${baseClean}/${canonicalPath}`;
|
|
1225
1242
|
const esc = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"');
|
|
1226
|
-
|
|
1243
|
+
// Skip the canonical <link> when the source head already declares one
|
|
1244
|
+
// (first-wins, per seo-aeo.md); hreflang alternates are still emitted since
|
|
1245
|
+
// those are framework-derived and rarely authored by hand.
|
|
1246
|
+
let out = skipCanonical ? '' : `<link rel="canonical" href="${esc(canonicalHref)}">\n`;
|
|
1227
1247
|
if (locales.length > 1) {
|
|
1228
1248
|
const currentLocale = locales.find((l) => pathSeg === l || pathSeg.startsWith(l + '/')) || defaultLoc;
|
|
1229
1249
|
const logicalRoute =
|
|
@@ -1581,7 +1601,8 @@ function generateLocaleVariantHtml({
|
|
|
1581
1601
|
html = rewriteHtmlAssetPaths(html, fileSegments.length);
|
|
1582
1602
|
|
|
1583
1603
|
const liveBase = config.liveUrl.replace(/\/$/, '');
|
|
1584
|
-
const
|
|
1604
|
+
const hasSourceCanonical = /<link\b[^>]*\brel=(["'])\s*canonical\s*\1/i.test(html);
|
|
1605
|
+
const canonicalHreflang = buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, liveBase, { skipCanonical: hasSourceCanonical });
|
|
1585
1606
|
const ogLocale = buildOgLocale(pathSeg, locales, defaultLocale);
|
|
1586
1607
|
const injectOgLocale = ogLocale && hasOtherOgMeta(html);
|
|
1587
1608
|
if (injectOgLocale) html = stripOgLocaleFromHead(html);
|
|
@@ -2383,29 +2404,123 @@ function routeHtmlPath(outputDir, pathSeg) {
|
|
|
2383
2404
|
}
|
|
2384
2405
|
|
|
2385
2406
|
/**
|
|
2386
|
-
*
|
|
2387
|
-
*
|
|
2388
|
-
*
|
|
2389
|
-
*
|
|
2407
|
+
* Collect filesystem paths for all local-file data sources declared in
|
|
2408
|
+
* `manifest.json` that are relevant to the given locale. Caller stats them.
|
|
2409
|
+
*
|
|
2410
|
+
* Skips remote sources (URLs, Appwrite databases / storage) since they have
|
|
2411
|
+
* no local mtime. Locale-keyed JSON/YAML sources include only the matching
|
|
2412
|
+
* locale's file. Multilingual CSVs (`locales` key) include every listed file
|
|
2413
|
+
* because any column edit can affect the routed page.
|
|
2390
2414
|
*/
|
|
2391
|
-
function
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
const
|
|
2415
|
+
function collectDataSourceFiles(manifest, rootDir, effectiveLocale) {
|
|
2416
|
+
const files = [];
|
|
2417
|
+
const data = manifest?.data;
|
|
2418
|
+
if (!data || typeof data !== 'object') return files;
|
|
2419
|
+
|
|
2420
|
+
const isLocaleKey = (key) =>
|
|
2421
|
+
/^[a-z]{2,3}(?:-[A-Z][a-zA-Z]{1,7})?$/.test(key);
|
|
2422
|
+
|
|
2423
|
+
const localeMatches = (key) => {
|
|
2424
|
+
if (!isLocaleKey(key)) return false;
|
|
2425
|
+
if (effectiveLocale) return key === effectiveLocale;
|
|
2426
|
+
// No locale context: include any locale-shaped key.
|
|
2427
|
+
return true;
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
const isLocalPath = (s) => typeof s === 'string' && !/^https?:\/\//i.test(s);
|
|
2431
|
+
const toAbs = (p) => join(rootDir, p.replace(/^\//, ''));
|
|
2432
|
+
|
|
2433
|
+
for (const value of Object.values(data)) {
|
|
2434
|
+
// Plain string path → single locale-agnostic file.
|
|
2435
|
+
if (isLocalPath(value)) {
|
|
2436
|
+
files.push(toAbs(value));
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
if (!value || typeof value !== 'object') continue;
|
|
2440
|
+
// Skip cloud / remote sources — they have no local file to stat.
|
|
2441
|
+
if (value.url || value.appwriteDatabaseId || value.appwriteTableId || value.appwriteBucketId) continue;
|
|
2442
|
+
|
|
2443
|
+
for (const [key, v] of Object.entries(value)) {
|
|
2444
|
+
// Multilingual CSV: { locales: "/p.csv" } or { locales: ["/a.csv", "/b.csv"] }
|
|
2445
|
+
if (key === 'locales') {
|
|
2446
|
+
if (isLocalPath(v)) files.push(toAbs(v));
|
|
2447
|
+
else if (Array.isArray(v)) {
|
|
2448
|
+
for (const p of v) if (isLocalPath(p)) files.push(toAbs(p));
|
|
2449
|
+
}
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
// Colorpicker palette: { colorpicker: "/p.yaml" } or { colorpicker: { en: ..., fr: ... } }
|
|
2453
|
+
if (key === 'colorpicker') {
|
|
2454
|
+
if (isLocalPath(v)) files.push(toAbs(v));
|
|
2455
|
+
else if (v && typeof v === 'object') {
|
|
2456
|
+
for (const [k, p] of Object.entries(v)) {
|
|
2457
|
+
if (localeMatches(k) && isLocalPath(p)) files.push(toAbs(p));
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
// Locale-keyed JSON/YAML: { en: "/p.en.json", fr: "/p.fr.json" }
|
|
2463
|
+
if (localeMatches(key) && isLocalPath(v)) {
|
|
2464
|
+
files.push(toAbs(v));
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
return files;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* Best-effort per-route lastmod date. Takes the most recent mtime across:
|
|
2474
|
+
* 1. Backing source-file conventions (markdown under articles/, pages/<path>.html)
|
|
2475
|
+
* so direct content edits are reflected.
|
|
2476
|
+
* 2. Data-source files registered in `manifest.json` that are relevant to the
|
|
2477
|
+
* route's locale, so changes to JSON/YAML/CSV content driving the page
|
|
2478
|
+
* bump the date too (important for translated sites).
|
|
2479
|
+
*
|
|
2480
|
+
* Falls back to the prerendered HTML's own mtime only when nothing else is
|
|
2481
|
+
* statable — the HTML mtime reflects rebuild time rather than content change
|
|
2482
|
+
* time, so we prefer source/data mtimes when any exist.
|
|
2483
|
+
*/
|
|
2484
|
+
function routeLastModDate(rootDir, outputDir, pathSeg, manifest, localeList, defaultLocale) {
|
|
2485
|
+
// Detect a locale prefix on the path (e.g. "fr/about" → locale "fr",
|
|
2486
|
+
// unlocalized "about"). For unprefixed paths in a multi-locale site we
|
|
2487
|
+
// fall back to the default locale when matching data-source locale keys.
|
|
2488
|
+
let locale = null;
|
|
2489
|
+
let unlocalizedPath = pathSeg;
|
|
2490
|
+
if (Array.isArray(localeList) && localeList.length) {
|
|
2491
|
+
const first = pathSeg.split('/')[0];
|
|
2492
|
+
if (localeList.includes(first)) {
|
|
2493
|
+
locale = first;
|
|
2494
|
+
unlocalizedPath = pathSeg.slice(first.length + 1);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
const effectiveLocale = locale || defaultLocale || null;
|
|
2498
|
+
|
|
2499
|
+
// Source-file candidates from common conventions, keyed on the unlocalized
|
|
2500
|
+
// path (markdown files typically aren't per-locale duplicates).
|
|
2501
|
+
const stripPrefix = unlocalizedPath.replace(/^(?:docs|blog|articles|posts|guides)\//, '');
|
|
2397
2502
|
const candidates = [
|
|
2398
2503
|
join(rootDir, 'articles', `${stripPrefix}.md`),
|
|
2399
|
-
join(rootDir, 'articles', `${
|
|
2400
|
-
join(rootDir, 'pages', `${
|
|
2401
|
-
join(rootDir, `${
|
|
2504
|
+
join(rootDir, 'articles', `${unlocalizedPath}.md`),
|
|
2505
|
+
join(rootDir, 'pages', `${unlocalizedPath}.html`),
|
|
2506
|
+
join(rootDir, `${unlocalizedPath}.md`),
|
|
2402
2507
|
];
|
|
2508
|
+
|
|
2509
|
+
// Add data-source files relevant to this locale.
|
|
2510
|
+
if (manifest) {
|
|
2511
|
+
candidates.push(...collectDataSourceFiles(manifest, rootDir, effectiveLocale));
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// Take the max mtime across all source / data candidates.
|
|
2515
|
+
let latest = null;
|
|
2403
2516
|
for (const c of candidates) {
|
|
2404
2517
|
try {
|
|
2405
2518
|
const s = statSync(c);
|
|
2406
|
-
if (s.isFile()
|
|
2519
|
+
if (s.isFile() && (!latest || s.mtime > latest)) latest = s.mtime;
|
|
2407
2520
|
} catch { /* not found */ }
|
|
2408
2521
|
}
|
|
2522
|
+
if (latest) return latest.toISOString().slice(0, 10);
|
|
2523
|
+
|
|
2409
2524
|
// Fallback to the prerendered output mtime (always present).
|
|
2410
2525
|
try {
|
|
2411
2526
|
const out = routeHtmlPath(outputDir, pathSeg || '');
|
|
@@ -2420,6 +2535,7 @@ function writeSeoFiles(outputDir, pathList, liveUrl, locales, defaultLocale, ctx
|
|
|
2420
2535
|
const localeList = Array.isArray(locales) ? locales : [];
|
|
2421
2536
|
const multiLocale = localeList.length > 1;
|
|
2422
2537
|
const rootDir = ctx.rootDir || '';
|
|
2538
|
+
const manifest = ctx.manifest || null;
|
|
2423
2539
|
|
|
2424
2540
|
writeFileSync(
|
|
2425
2541
|
join(outputDir, 'robots.txt'),
|
|
@@ -2445,7 +2561,7 @@ Sitemap: ${base}/sitemap.xml
|
|
|
2445
2561
|
body += `\n <xhtml:link rel="alternate" hreflang="${escapeXmlText(hreflang)}" href="${escapeXmlText(href)}" />`;
|
|
2446
2562
|
}
|
|
2447
2563
|
}
|
|
2448
|
-
const lastmod = routeLastModDate(rootDir, outputDir, pathSeg);
|
|
2564
|
+
const lastmod = routeLastModDate(rootDir, outputDir, pathSeg, manifest, localeList, defaultLocale);
|
|
2449
2565
|
body += `\n <lastmod>${lastmod}</lastmod>
|
|
2450
2566
|
<changefreq>monthly</changefreq>
|
|
2451
2567
|
<priority>${path === '' ? '1.0' : '0.8'}</priority>`;
|
|
@@ -2716,6 +2832,42 @@ function copyProjectIntoDist(rootResolved, outputResolved) {
|
|
|
2716
2832
|
COPY_EXCLUDE.delete(outputDirName);
|
|
2717
2833
|
}
|
|
2718
2834
|
|
|
2835
|
+
/** Remove bare `@import "tailwindcss"` (and `tailwindcss/*` sub-imports) from
|
|
2836
|
+
* CSS files copied into the output.
|
|
2837
|
+
*
|
|
2838
|
+
* Tailwind v4 conventions put `@import "tailwindcss";` at the top of a
|
|
2839
|
+
* project's main CSS so the build tool pulls in the framework. When that same
|
|
2840
|
+
* file is also linked directly in the browser (as Manifest's
|
|
2841
|
+
* `manifest.theme.css` convention does), the browser's native CSS loader
|
|
2842
|
+
* resolves the bare specifier against the page origin and fetches
|
|
2843
|
+
* `/tailwindcss` — which 404s to the SPA shell (text/html), tripping
|
|
2844
|
+
* "Refused to apply style … is not a supported stylesheet MIME type" (flagged
|
|
2845
|
+
* by PageSpeed Best Practices). Manifest supplies Tailwind its own way
|
|
2846
|
+
* (compiled `prerender.tailwind.css` for MPA, the Play-CDN style engine for
|
|
2847
|
+
* SPA), so a raw import in a browser-served stylesheet is always redundant and
|
|
2848
|
+
* harmful. Strip it from the emitted copies only; source files are untouched. */
|
|
2849
|
+
function stripTailwindCssImportsFromOutput(outputDir) {
|
|
2850
|
+
const importRx = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']\s*\)?[^;\n]*;?[ \t]*\r?\n?/gi;
|
|
2851
|
+
const walk = (dir) => {
|
|
2852
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
2853
|
+
if (ent.name.startsWith('.')) continue;
|
|
2854
|
+
const p = join(dir, ent.name);
|
|
2855
|
+
if (ent.isDirectory()) {
|
|
2856
|
+
if (ent.name === 'node_modules') continue;
|
|
2857
|
+
walk(p);
|
|
2858
|
+
} else if (ent.name.endsWith('.css') && ent.name !== 'prerender.tailwind.css') {
|
|
2859
|
+
try {
|
|
2860
|
+
const css = readFileSync(p, 'utf8');
|
|
2861
|
+
if (!/tailwindcss/i.test(css)) continue;
|
|
2862
|
+
const next = css.replace(importRx, '');
|
|
2863
|
+
if (next !== css) writeFileSync(p, next, 'utf8');
|
|
2864
|
+
} catch { /* unreadable file — skip */ }
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
};
|
|
2868
|
+
walk(outputDir);
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2719
2871
|
// --- Main --------------------------------------------------------------------
|
|
2720
2872
|
|
|
2721
2873
|
async function main() {
|
|
@@ -2819,8 +2971,16 @@ async function runPrerender(config) {
|
|
|
2819
2971
|
}
|
|
2820
2972
|
mkdirSync(outputResolved, { recursive: true });
|
|
2821
2973
|
copyProjectIntoDist(rootResolved, outputResolved);
|
|
2974
|
+
// Projects that use data-tailwind get their Tailwind from Manifest (compiled
|
|
2975
|
+
// prerender.tailwind.css below, or the runtime style engine). A leftover
|
|
2976
|
+
// `@import "tailwindcss"` in a browser-linked stylesheet (e.g. the
|
|
2977
|
+
// manifest.theme.css convention) would make the browser fetch /tailwindcss
|
|
2978
|
+
// and fail; strip those imports from the copied CSS.
|
|
2979
|
+
if (indexHtmlUsesTailwind(rootResolved)) {
|
|
2980
|
+
stripTailwindCssImportsFromOutput(outputResolved);
|
|
2981
|
+
}
|
|
2822
2982
|
|
|
2823
|
-
const pre = manifest.prerender ?? {};
|
|
2983
|
+
const pre = manifest.render ?? manifest.prerender ?? {};
|
|
2824
2984
|
const bundleUtilities = pre.utilitiesBundle !== false;
|
|
2825
2985
|
const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved, pre);
|
|
2826
2986
|
const utilityBlocks = [];
|
|
@@ -3753,11 +3913,20 @@ async function runPrerender(config) {
|
|
|
3753
3913
|
await page.evaluate(() => {
|
|
3754
3914
|
const A = window.Alpine;
|
|
3755
3915
|
const runBatch = typeof A?.mutateDom === 'function' ? (fn) => A.mutateDom(fn) : (fn) => fn();
|
|
3756
|
-
|
|
3916
|
+
// Collect every loop-scope identifier from the x-for LHS, including
|
|
3917
|
+
// destructuring forms — `item`, `(item, index)`, `[key, val]`,
|
|
3918
|
+
// `{ a, b }`. The old single/paren-only regex skipped destructured
|
|
3919
|
+
// loops entirely, leaving bindings like x-text="file?.label" on baked
|
|
3920
|
+
// clones; at runtime Alpine evaluated them outside the iteration scope
|
|
3921
|
+
// and threw "file is not defined".
|
|
3922
|
+
const extractLoopVars = (xForExpr) => {
|
|
3923
|
+
const m = String(xForExpr || '').match(/^([\s\S]*?)\s+(?:in|of)\s+/);
|
|
3924
|
+
return m ? (m[1].match(/[A-Za-z_$][\w$]*/g) || []) : [];
|
|
3925
|
+
};
|
|
3757
3926
|
// Include x-init: expanded clones still had x-init="getDescription(article)" etc.; Alpine then throws (article undefined).
|
|
3758
3927
|
const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-init|x-icon|x-on:|@)/;
|
|
3759
3928
|
const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
|
|
3760
|
-
const stripLoopBindings = (el,
|
|
3929
|
+
const stripLoopBindings = (el, loopVars) => {
|
|
3761
3930
|
const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
|
|
3762
3931
|
for (const node of nodes) {
|
|
3763
3932
|
// Skip elements inside data-hydrate islands — their bindings must remain live
|
|
@@ -3766,7 +3935,7 @@ async function runPrerender(config) {
|
|
|
3766
3935
|
for (const attr of attrs) {
|
|
3767
3936
|
if (!bindingAttrRegex.test(attr.name)) continue;
|
|
3768
3937
|
const expr = attr.value || '';
|
|
3769
|
-
if (
|
|
3938
|
+
if (loopVars.some((v) => hasVar(expr, v))) {
|
|
3770
3939
|
const name = attr.name;
|
|
3771
3940
|
if (name === 'x-text' || name === 'x-html') {
|
|
3772
3941
|
if ((node.textContent || '').trim() || (node.innerHTML || '').trim()) {
|
|
@@ -3808,10 +3977,8 @@ async function runPrerender(config) {
|
|
|
3808
3977
|
document.querySelectorAll('template[x-for]').forEach((tpl) => {
|
|
3809
3978
|
if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
|
|
3810
3979
|
const xFor = (tpl.getAttribute('x-for') || '').trim();
|
|
3811
|
-
const
|
|
3812
|
-
|
|
3813
|
-
const indexVar = m ? (m[2] || '') : '';
|
|
3814
|
-
if (!itemVar && !indexVar) return;
|
|
3980
|
+
const loopVars = extractLoopVars(xFor);
|
|
3981
|
+
if (!loopVars.length) return;
|
|
3815
3982
|
|
|
3816
3983
|
const first = tpl.content?.firstElementChild;
|
|
3817
3984
|
if (!first) return;
|
|
@@ -3820,7 +3987,7 @@ async function runPrerender(config) {
|
|
|
3820
3987
|
let next = tpl.nextElementSibling;
|
|
3821
3988
|
while (next) {
|
|
3822
3989
|
if (next.tagName !== tag) break;
|
|
3823
|
-
stripLoopBindings(next,
|
|
3990
|
+
stripLoopBindings(next, loopVars);
|
|
3824
3991
|
next = next.nextElementSibling;
|
|
3825
3992
|
}
|
|
3826
3993
|
});
|
|
@@ -3914,10 +4081,13 @@ async function runPrerender(config) {
|
|
|
3914
4081
|
// Remove orphan x-for clones that still reference loop-scope vars (e.g. image/index)
|
|
3915
4082
|
// outside their template scope. These throw Alpine errors in live static hosting.
|
|
3916
4083
|
await page.evaluate(() => {
|
|
3917
|
-
const
|
|
4084
|
+
const extractLoopVars = (xForExpr) => {
|
|
4085
|
+
const m = String(xForExpr || '').match(/^([\s\S]*?)\s+(?:in|of)\s+/);
|
|
4086
|
+
return m ? (m[1].match(/[A-Za-z_$][\w$]*/g) || []) : [];
|
|
4087
|
+
};
|
|
3918
4088
|
const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-init|x-icon|x-on:|@)/;
|
|
3919
4089
|
const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
|
|
3920
|
-
const elementReferencesLoopScope = (el,
|
|
4090
|
+
const elementReferencesLoopScope = (el, loopVars) => {
|
|
3921
4091
|
if (!el) return false;
|
|
3922
4092
|
const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
|
|
3923
4093
|
for (const node of nodes) {
|
|
@@ -3925,7 +4095,7 @@ async function runPrerender(config) {
|
|
|
3925
4095
|
for (const attr of attrs) {
|
|
3926
4096
|
if (!bindingAttrRegex.test(attr.name)) continue;
|
|
3927
4097
|
const expr = attr.value || '';
|
|
3928
|
-
if (
|
|
4098
|
+
if (loopVars.some((v) => hasVar(expr, v))) return true;
|
|
3929
4099
|
}
|
|
3930
4100
|
}
|
|
3931
4101
|
return false;
|
|
@@ -3935,10 +4105,8 @@ async function runPrerender(config) {
|
|
|
3935
4105
|
// Running this on all x-for templates can remove valid prerendered list items.
|
|
3936
4106
|
document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
|
|
3937
4107
|
const xFor = (tpl.getAttribute('x-for') || '').trim();
|
|
3938
|
-
const
|
|
3939
|
-
|
|
3940
|
-
const indexVar = m ? (m[2] || '') : '';
|
|
3941
|
-
if (!itemVar && !indexVar) return;
|
|
4108
|
+
const loopVars = extractLoopVars(xFor);
|
|
4109
|
+
if (!loopVars.length) return;
|
|
3942
4110
|
|
|
3943
4111
|
const first = tpl.content?.firstElementChild;
|
|
3944
4112
|
if (!first) return;
|
|
@@ -3949,7 +4117,7 @@ async function runPrerender(config) {
|
|
|
3949
4117
|
const sameTag = next.tagName === tag;
|
|
3950
4118
|
if (!sameTag) break;
|
|
3951
4119
|
|
|
3952
|
-
const referencesLoopScope = elementReferencesLoopScope(next,
|
|
4120
|
+
const referencesLoopScope = elementReferencesLoopScope(next, loopVars);
|
|
3953
4121
|
|
|
3954
4122
|
const toRemove = next;
|
|
3955
4123
|
next = next.nextElementSibling;
|
|
@@ -3995,6 +4163,52 @@ async function runPrerender(config) {
|
|
|
3995
4163
|
toRemove.forEach((el) => { if (document.contains(el)) el.remove(); });
|
|
3996
4164
|
});
|
|
3997
4165
|
|
|
4166
|
+
// Tag baked x-for/x-if clones that Alpine produced during prerender but
|
|
4167
|
+
// whose <template> is still in the output (so Alpine WILL re-render the
|
|
4168
|
+
// list/conditional at runtime). Without this, the runtime shows both the
|
|
4169
|
+
// baked copy AND Alpine's fresh render — a duplicate (the hero file tabs,
|
|
4170
|
+
// the docs eyebrow + article, etc.). We KEEP the baked copies in the
|
|
4171
|
+
// shipped HTML so crawlers see the content, and tag them so the loader can
|
|
4172
|
+
// remove them right before Alpine boots — giving exactly one live render.
|
|
4173
|
+
//
|
|
4174
|
+
// Identify clones via Alpine's own bookkeeping rather than DOM heuristics:
|
|
4175
|
+
// x-for tracks its generated elements in template._x_lookup, and x-if in
|
|
4176
|
+
// template._x_currentIfEl. Templates already removed earlier (static-bake
|
|
4177
|
+
// freeze) or whose clones were already stripped (dynamic collapse) simply
|
|
4178
|
+
// have nothing to tag here. data-hydrate islands are left untouched.
|
|
4179
|
+
await page.evaluate(() => {
|
|
4180
|
+
const tag = (el) => {
|
|
4181
|
+
if (!el || el.nodeType !== 1 || !el.setAttribute) return;
|
|
4182
|
+
if (el.closest('[data-hydrate]')) return;
|
|
4183
|
+
el.setAttribute('data-mnfst-prerender-clone', '1');
|
|
4184
|
+
};
|
|
4185
|
+
document.querySelectorAll('template[x-for]').forEach((tpl) => {
|
|
4186
|
+
if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
|
|
4187
|
+
// Prefer Alpine's own lookup when populated; otherwise fall back to
|
|
4188
|
+
// walking the consecutive same-tag siblings Alpine emitted right after
|
|
4189
|
+
// the template (the pattern the collapse passes already use).
|
|
4190
|
+
let tagged = false;
|
|
4191
|
+
const lookup = tpl._x_lookup;
|
|
4192
|
+
if (lookup) {
|
|
4193
|
+
try { Object.values(lookup).forEach((el) => { if (el) { tag(el); tagged = true; } }); } catch { /* not iterable */ }
|
|
4194
|
+
}
|
|
4195
|
+
if (tagged) return;
|
|
4196
|
+
const first = tpl.content && tpl.content.firstElementChild;
|
|
4197
|
+
if (!first) return;
|
|
4198
|
+
const cloneTag = first.tagName;
|
|
4199
|
+
let n = tpl.nextElementSibling;
|
|
4200
|
+
while (n && n.tagName === cloneTag) {
|
|
4201
|
+
const next = n.nextElementSibling;
|
|
4202
|
+
tag(n);
|
|
4203
|
+
n = next;
|
|
4204
|
+
}
|
|
4205
|
+
});
|
|
4206
|
+
document.querySelectorAll('template[x-if]').forEach((tpl) => {
|
|
4207
|
+
if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
|
|
4208
|
+
tag(tpl._x_currentIfEl);
|
|
4209
|
+
});
|
|
4210
|
+
});
|
|
4211
|
+
|
|
3998
4212
|
// SEO / AEO meta injection — see resolveConfig().seo for precedence layers.
|
|
3999
4213
|
// Runs in the live page so prerender.meta expressions can use Alpine context
|
|
4000
4214
|
// (real $x.* evaluation, not yaml-only paths). Each pass only fills
|
|
@@ -4072,7 +4286,8 @@ async function runPrerender(config) {
|
|
|
4072
4286
|
|
|
4073
4287
|
html = rewriteHtmlAssetPaths(html, fileSegments.length);
|
|
4074
4288
|
const liveBase = config.liveUrl.replace(/\/$/, '');
|
|
4075
|
-
const
|
|
4289
|
+
const hasSourceCanonical = /<link\b[^>]*\brel=(["'])\s*canonical\s*\1/i.test(html);
|
|
4290
|
+
const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase, { skipCanonical: hasSourceCanonical });
|
|
4076
4291
|
const ogLocale = buildOgLocale(is404 ? '' : pathSeg, locales, defaultLocale);
|
|
4077
4292
|
const injectOgLocale = ogLocale && hasOtherOgMeta(html);
|
|
4078
4293
|
if (injectOgLocale) html = stripOgLocaleFromHead(html);
|
|
@@ -4094,6 +4309,7 @@ async function runPrerender(config) {
|
|
|
4094
4309
|
);
|
|
4095
4310
|
// (Hydration contract was already injected into the raw HTML before
|
|
4096
4311
|
// the Node.js post-processing pipeline ran, so it's already present.)
|
|
4312
|
+
html = ensureDoctype(html);
|
|
4097
4313
|
mkdirSync(outDir, { recursive: true });
|
|
4098
4314
|
writeFileSync(outFile, html, 'utf8');
|
|
4099
4315
|
pushDebug({
|
|
@@ -4276,7 +4492,7 @@ async function runPrerender(config) {
|
|
|
4276
4492
|
const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
|
|
4277
4493
|
const outDir = join(config.output, ...fileSegments);
|
|
4278
4494
|
mkdirSync(outDir, { recursive: true });
|
|
4279
|
-
writeFileSync(join(outDir, 'index.html'), html, 'utf8');
|
|
4495
|
+
writeFileSync(join(outDir, 'index.html'), ensureDoctype(html), 'utf8');
|
|
4280
4496
|
} catch (err) {
|
|
4281
4497
|
failedPaths.push({ path: displayPath, message: err?.message ?? String(err) });
|
|
4282
4498
|
process.stderr.write(`prerender: substitution failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
|
|
@@ -4317,6 +4533,7 @@ async function runPrerender(config) {
|
|
|
4317
4533
|
defaultLocale,
|
|
4318
4534
|
{
|
|
4319
4535
|
rootDir: config.root,
|
|
4536
|
+
manifest: config.manifest,
|
|
4320
4537
|
siteName: config.seo?.siteName,
|
|
4321
4538
|
siteDescription: config.seo?.siteDescription,
|
|
4322
4539
|
}
|