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.
Files changed (2) hide show
  1. package/manifest.render.mjs +255 -38
  2. package/package.json +1 -1
@@ -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
- const pre = manifest.prerender ?? {};
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, '&amp;').replace(/"/g, '&quot;');
1226
- let out = `<link rel="canonical" href="${esc(canonicalHref)}">\n`;
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 canonicalHreflang = buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, liveBase);
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
- * Best-effort per-route lastmod date. We pick the prerendered HTML file's
2387
- * mtime that file IS regenerated on every prerender, so it's no better than
2388
- * "today" for unchanged content. Fallback hierarchy: 1) source markdown if
2389
- * discoverable under articles/<path>.md; 2) prerendered HTML mtime; 3) today.
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 routeLastModDate(rootDir, outputDir, pathSeg) {
2392
- // Try common source-file conventions first so the date reflects content
2393
- // changes rather than the prerender run. Strip leading section prefix
2394
- // ("docs/", "blog/", "articles/") since markdown files typically live
2395
- // under articles/ keyed by the remaining path.
2396
- const stripPrefix = pathSeg.replace(/^(?:docs|blog|articles|posts|guides)\//, '');
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', `${pathSeg}.md`),
2400
- join(rootDir, 'pages', `${pathSeg}.html`),
2401
- join(rootDir, `${pathSeg}.md`),
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()) return s.mtime.toISOString().slice(0, 10);
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
- const loopVarRegex = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
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, itemVar, indexVar) => {
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 (hasVar(expr, itemVar) || hasVar(expr, indexVar)) {
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 m = xFor.match(loopVarRegex);
3812
- const itemVar = m ? (m[1] || m[3] || '') : '';
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, itemVar, indexVar);
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 loopVarRegex = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
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, itemVar, indexVar) => {
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 (hasVar(expr, itemVar) || hasVar(expr, indexVar)) return true;
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 m = xFor.match(loopVarRegex);
3939
- const itemVar = m ? (m[1] || m[3] || '') : '';
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, itemVar, indexVar);
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 canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.26",
3
+ "version": "0.5.28",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {