mnfst-render 0.3.7 → 0.3.9

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 +92 -25
  2. package/package.json +1 -1
@@ -252,6 +252,13 @@ function loadConfig(rootDir) {
252
252
  return manifest;
253
253
  }
254
254
 
255
+ function normalizeLocaleRouteExclude(val) {
256
+ if (val == null) return [];
257
+ if (Array.isArray(val)) return val.map((s) => String(s).trim()).filter(Boolean);
258
+ if (typeof val === 'string') return val.split(',').map((s) => s.trim()).filter(Boolean);
259
+ return [];
260
+ }
261
+
255
262
  function resolveConfig() {
256
263
  const cli = parseArgs();
257
264
  const cwd = process.cwd();
@@ -274,6 +281,10 @@ function resolveConfig() {
274
281
  output: resolve(root, cli.output ?? pre.output ?? 'website'),
275
282
  root,
276
283
  routerBase: pre.routerBase ?? null,
284
+ /** Logical path prefixes (after locale) that skip sticky locale prefix; see manifest:locale-route-exclude */
285
+ localeRouteExclude: normalizeLocaleRouteExclude(
286
+ pre.localeRouteExclude ?? pre.localeStickyExclude
287
+ ),
277
288
  locales: pre.locales,
278
289
  redirects: Array.isArray(pre.redirects) ? pre.redirects : [],
279
290
  wait: cli.wait ?? pre.wait ?? null,
@@ -853,19 +864,26 @@ function depthFromOutputRoot(outputDir, filePath) {
853
864
  return rel.split(sep).filter(Boolean).length;
854
865
  }
855
866
 
856
- /** Inject stylesheet link with correct relative href for static hosting (after prerender wrote files). */
857
- function postProcessInjectStylesheetLink(outputDir, filename) {
867
+ /** Root-absolute path for prerender bundles (same URL from every page depth; supports manifest:router-base). */
868
+ function buildRootAssetPath(routerBasePath, filename) {
869
+ const base = String(routerBasePath || '').replace(/^\/+|\/+$/g, '');
870
+ const name = String(filename || '').replace(/^\/+/, '');
871
+ const path = base ? `${base}/${name}` : name;
872
+ return '/' + path.replace(/\/{2,}/g, '/');
873
+ }
874
+
875
+ /** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/). */
876
+ function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath) {
858
877
  const cssPath = join(outputDir, filename);
859
878
  if (!existsSync(cssPath)) return;
860
879
  const stat = statSync(cssPath);
861
880
  if (stat.size === 0) return;
862
881
 
882
+ const href = buildRootAssetPath(routerBasePath, filename);
883
+ const tag = `<link rel="stylesheet" href="${href}">`;
863
884
  const files = walkHtmlFiles(outputDir);
864
885
  for (const file of files) {
865
886
  let html = readFileSync(file, 'utf8');
866
- const depth = depthFromOutputRoot(outputDir, file);
867
- const prefix = depth ? '../'.repeat(depth) : '';
868
- const tag = `<link rel="stylesheet" href="${prefix}${filename}">`;
869
887
  html = injectBeforeHeadClose(html, tag);
870
888
  writeFileSync(file, html, 'utf8');
871
889
  }
@@ -879,6 +897,10 @@ function stripDuplicatedLoopDirectives(html) {
879
897
  return html;
880
898
  }
881
899
 
900
+ function isHydrateMarkedAttrs(attrsStr) {
901
+ return /\sdata-prerender-hydrate(?:\s*=|[\s>])/i.test(attrsStr || '');
902
+ }
903
+
882
904
  // --- Strip x-text and x-html that reference $x when static/SEO (content already in snapshot).
883
905
  // Do NOT strip when expression is user-driven: $route(, $search, $query. Those stay so Alpine can update.
884
906
  // Same rule for :attr in stripPrerenderDynamicBindings: bindings with $x are kept (content stays for SEO).
@@ -888,9 +910,13 @@ function stripPrerenderedXDataDirectives(html) {
888
910
  if (expr.includes('$search') || expr.includes('$query')) return false;
889
911
  return true;
890
912
  }
891
- let out = html.replace(/\s+x-text="([^"]*\$x[^"]*)"/g, (match, expr) => (isStatic(expr) ? '' : match));
892
- out = out.replace(/\s+x-html="([^"]*\$x[^"]*)"/g, (match, expr) => (isStatic(expr) ? '' : match));
893
- return out;
913
+ return html.replace(/<(\w+)([^>]*)>/g, (full, tag, attrs) => {
914
+ if (isHydrateMarkedAttrs(attrs)) return full;
915
+ let outAttrs = attrs;
916
+ outAttrs = outAttrs.replace(/\s+x-text="([^"]*\$x[^"]*)"/g, (match, expr) => (isStatic(expr) ? '' : match));
917
+ outAttrs = outAttrs.replace(/\s+x-html="([^"]*\$x[^"]*)"/g, (match, expr) => (isStatic(expr) ? '' : match));
918
+ return `<${tag}${outAttrs}>`;
919
+ });
894
920
  }
895
921
 
896
922
  // --- Don't bake Alpine-only state into the snapshot; only $x-driven content should be prerendered.
@@ -903,6 +929,7 @@ function stripPrerenderedXDataDirectives(html) {
903
929
  function stripPrerenderDynamicBindings(html) {
904
930
  return html.replace(/<(\w+)([^>]*)>/g, (match, tagName, attrsStr) => {
905
931
  if (tagName.toLowerCase() === 'script') return match;
932
+ if (isHydrateMarkedAttrs(attrsStr)) return match;
906
933
  const isAnchor = tagName.toLowerCase() === 'a';
907
934
  const isImg = tagName.toLowerCase() === 'img';
908
935
  let workAttrs = attrsStr;
@@ -942,6 +969,7 @@ function stripPrerenderDynamicBindings(html) {
942
969
  // Drop :src / x-bind:src when img already has a baked src= (x-for / iterator expressions break hydrate).
943
970
  function stripRedundantImgSrcBindings(html) {
944
971
  return html.replace(/<img\b([^>]*)>/gi, (full, attrs) => {
972
+ if (isHydrateMarkedAttrs(attrs)) return full;
945
973
  const srcM = attrs.match(/\ssrc=(["'])([\s\S]*?)\1/i);
946
974
  if (!srcM || !String(srcM[2] || '').trim()) return full;
947
975
  if (!/\s:src\s*=|\sx-bind:src\s*=/i.test(attrs)) return full;
@@ -960,6 +988,7 @@ function stripRedundantImgSrcBindings(html) {
960
988
  // loop/item expressions while the attribute remains for CSS (e.g. inline layout that keys off [x-icon]).
961
989
  function stripResolvedXIconDirectives(html) {
962
990
  return html.replace(/<i\b([^>]*)>([\s\S]*?)<\/i>/gi, (full, attrs, inner) => {
991
+ if (isHydrateMarkedAttrs(attrs)) return full;
963
992
  if (!/\sx-icon\s*=/i.test(attrs)) return full;
964
993
  if (!/<svg\b/i.test(inner) || !/\bdata-icon\s*=/i.test(inner)) return full;
965
994
  const cleaned = attrs
@@ -971,6 +1000,10 @@ function stripResolvedXIconDirectives(html) {
971
1000
  });
972
1001
  }
973
1002
 
1003
+ function stripPrerenderHydrateMarkers(html) {
1004
+ return html.replace(/\sdata-prerender-hydrate(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
1005
+ }
1006
+
974
1007
  function markPrerenderedManifestComponents(html) {
975
1008
  return html.replace(/<(x-[a-z][\w-]*)([^>]*)>/gi, (full, tag, attrs) => {
976
1009
  const a = attrs || '';
@@ -1007,6 +1040,10 @@ function stripEmptyInlineMaskStyles(html) {
1007
1040
  // All project assets are copied into output, so root-relative paths become relative within output.
1008
1041
  // Do NOT rewrite href on <a> tags (navigation links); only rewrite link/script/img so router gets clean paths.
1009
1042
 
1043
+ function isPrerenderBundleAssetPath(pathAfterSlash) {
1044
+ return /(^|\/)prerender\.(tailwind|utilities)\.css$/.test(pathAfterSlash);
1045
+ }
1046
+
1010
1047
  function rewriteHtmlAssetPaths(html, depthWithinOutput) {
1011
1048
  const prefix = depthWithinOutput > 0 ? '../'.repeat(depthWithinOutput) : '';
1012
1049
  if (!prefix) return html;
@@ -1016,9 +1053,10 @@ function rewriteHtmlAssetPaths(html, depthWithinOutput) {
1016
1053
  const tag = htmlBeforeMatch.slice(lastOpen + 1).match(/^(\w+)/);
1017
1054
  return tag && tag[1].toLowerCase() === 'a';
1018
1055
  }
1019
- let out = html.replace(/(\s(href|src)=["'])\/(?!\/)/g, (match, lead, attr, offset, fullString) => {
1056
+ let out = html.replace(/(\s(href|src)=["'])\/(?!\/)([^'"]*)/g, (match, lead, _attr, rest, offset, fullString) => {
1020
1057
  if (isAnchorTag(fullString.slice(0, offset))) return match;
1021
- return lead + prefix;
1058
+ if (isPrerenderBundleAssetPath(rest)) return match;
1059
+ return lead + prefix + rest;
1022
1060
  });
1023
1061
  out = out.replace(/(\s(href|src)=["'])(\.\.\/)+/g, (match, lead, attr, dots, offset, fullString) => {
1024
1062
  if (isAnchorTag(fullString.slice(0, offset))) return match;
@@ -1027,6 +1065,17 @@ function rewriteHtmlAssetPaths(html, depthWithinOutput) {
1027
1065
  return out;
1028
1066
  }
1029
1067
 
1068
+ // Alpine x-data drives radio state; baked checked="" from the live DOM (e.g. yearly) fights monthly defaults.
1069
+ function stripPrerenderBakedRadioCheckedForXModel(html) {
1070
+ return html.replace(/<input\b([^>]*)>/gi, (full, attrs) => {
1071
+ if (!/\btype\s*=\s*["']radio["']/i.test(attrs)) return full;
1072
+ if (!/\bx-model\s*=/i.test(attrs)) return full;
1073
+ const next = attrs.replace(/\s+checked(?:\s*=\s*["'][^"']*["']|\s*=\s*[^\s>]+)?/gi, '');
1074
+ if (next === attrs) return full;
1075
+ return `<input${next}>`;
1076
+ });
1077
+ }
1078
+
1030
1079
  // --- Canonical and hreflang (per-page injection) ---
1031
1080
 
1032
1081
  function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
@@ -1761,15 +1810,27 @@ async function runPrerender(config) {
1761
1810
  });
1762
1811
  });
1763
1812
 
1813
+ // Mark data-hydrate islands so static compile transforms skip them.
1814
+ await page.evaluate(() => {
1815
+ document.querySelectorAll('[data-hydrate]').forEach((root) => {
1816
+ root.setAttribute('data-prerender-hydrate', '1');
1817
+ root.querySelectorAll('*').forEach((el) => el.setAttribute('data-prerender-hydrate', '1'));
1818
+ });
1819
+ });
1820
+
1764
1821
  // x-for lists: keep static lists in the HTML for SEO; collapse only dynamic lists so Alpine re-renders.
1765
- // Explicit: data-dynamic or data-prerender="dynamic"|"skip". Inferred: x-for uses $search/$query,
1822
+ // Explicit: data-prerender="dynamic"|"skip". Inferred: x-for uses $search/$query,
1766
1823
  // $url, $auth, or iterates over getter names (filtered*, results, searchResults). See docs prerender + local.data.
1767
1824
  await page.evaluate(() => {
1768
1825
  document.querySelectorAll('template[x-for]').forEach((tpl) => {
1826
+ if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) {
1827
+ tpl.removeAttribute('data-prerender-collapsed');
1828
+ tpl.removeAttribute('data-prerender-static-generated');
1829
+ return;
1830
+ }
1769
1831
  const xFor = (tpl.getAttribute('x-for') || '').trim();
1770
1832
  const prerender = (tpl.getAttribute('data-prerender') || '').toLowerCase();
1771
- const hasDataDynamic = tpl.hasAttribute('data-dynamic');
1772
- const explicit = hasDataDynamic || prerender === 'dynamic' || prerender === 'skip';
1833
+ const explicit = prerender === 'dynamic' || prerender === 'skip';
1773
1834
  const inferred = xFor.includes('$search') || xFor.includes('$query') ||
1774
1835
  xFor.includes('$url') || xFor.includes('$auth') ||
1775
1836
  /\bin\s+(filtered\w*|results|searchResults)\b/.test(xFor);
@@ -1883,6 +1944,7 @@ async function runPrerender(config) {
1883
1944
  };
1884
1945
 
1885
1946
  document.querySelectorAll('template[x-for]').forEach((tpl) => {
1947
+ if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) return;
1886
1948
  const xFor = (tpl.getAttribute('x-for') || '').trim();
1887
1949
  const m = xFor.match(loopVarRegex);
1888
1950
  const itemVar = m ? (m[1] || m[3] || '') : '';
@@ -1911,6 +1973,7 @@ async function runPrerender(config) {
1911
1973
  const runBatch = typeof A?.mutateDom === 'function' ? (fn) => A.mutateDom(fn) : (fn) => fn();
1912
1974
  runBatch(() => {
1913
1975
  document.querySelectorAll('template[x-for][data-prerender-static-generated="1"]').forEach((tpl) => {
1976
+ if (tpl.hasAttribute('data-prerender-hydrate') || tpl.closest('[data-prerender-hydrate]')) return;
1914
1977
  const parent = tpl.parentNode;
1915
1978
  if (!parent) {
1916
1979
  tpl.remove();
@@ -1983,15 +2046,6 @@ async function runPrerender(config) {
1983
2046
  });
1984
2047
  });
1985
2048
 
1986
- // Remove elements marked data-dynamic (so they are not in static HTML; client will render them).
1987
- // Skip <template> since we only collapse those above; other elements and their subtree are removed.
1988
- await page.evaluate(() => {
1989
- const toRemove = Array.from(document.querySelectorAll('[data-dynamic]')).filter((el) => el.tagName !== 'TEMPLATE');
1990
- const depth = (el) => { let d = 0; let n = el; while (n && n !== document.body) { d++; n = n.parentElement; } return d; };
1991
- toRemove.sort((a, b) => depth(a) - depth(b));
1992
- toRemove.forEach((el) => { if (document.contains(el)) el.remove(); });
1993
- });
1994
-
1995
2049
  const visibilityNormalizedPath = logicalPathToVisibilityNormalizedPath(pathSeg, locales);
1996
2050
  await page.evaluate((np) => {
1997
2051
  try {
@@ -2032,7 +2086,10 @@ async function runPrerender(config) {
2032
2086
  for (const b of extracted.blocks) utilityBlocks.push(b);
2033
2087
  }
2034
2088
  if (tailwindBuilt) {
2035
- html = injectBeforeHeadClose(html, '<link rel="stylesheet" href="/prerender.tailwind.css">');
2089
+ html = injectBeforeHeadClose(
2090
+ html,
2091
+ `<link rel="stylesheet" href="${buildRootAssetPath(routerBasePath, 'prerender.tailwind.css')}">`
2092
+ );
2036
2093
  }
2037
2094
  html = stripDuplicatedLoopDirectives(html);
2038
2095
  html = stripPrerenderedXDataDirectives(html);
@@ -2040,9 +2097,11 @@ async function runPrerender(config) {
2040
2097
  const xData = { manifest, content };
2041
2098
  html = resolveHeadXBindings(html, xData);
2042
2099
  html = stripPrerenderDynamicBindings(html);
2100
+ html = stripPrerenderBakedRadioCheckedForXModel(html);
2043
2101
  html = stripRedundantImgSrcBindings(html);
2044
2102
  html = stripEmptyInlineMaskStyles(html);
2045
2103
  html = stripResolvedXIconDirectives(html);
2104
+ html = stripPrerenderHydrateMarkers(html);
2046
2105
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
2047
2106
  const liveBase = config.liveUrl.replace(/\/$/, '');
2048
2107
  const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase);
@@ -2050,9 +2109,17 @@ async function runPrerender(config) {
2050
2109
  const injectOgLocale = ogLocale && hasOtherOgMeta(html);
2051
2110
  if (injectOgLocale) html = stripOgLocaleFromHead(html);
2052
2111
  const baseMeta = routerBasePath !== null ? `<meta name="manifest:router-base" content="${String(routerBasePath).replace(/"/g, '&quot;')}">\n` : '';
2112
+ const routeEx = config.localeRouteExclude || [];
2113
+ const routeMeta =
2114
+ routeEx.length > 0
2115
+ ? `<meta name="manifest:locale-route-exclude" content="${JSON.stringify(routeEx).replace(/"/g, '&quot;')}">\n`
2116
+ : '';
2053
2117
  const routeDepth = fileSegments.length;
2054
2118
  const prerenderedMeta = `<meta name="manifest:prerendered" content="1">\n`;
2055
- html = html.replace('</head>', `${canonicalHreflang}${injectOgLocale ? ogLocale : ''}${baseMeta}${prerenderedMeta}<meta name="manifest:router-base-depth" content="${routeDepth}">\n</head>`);
2119
+ html = html.replace(
2120
+ '</head>',
2121
+ `${canonicalHreflang}${injectOgLocale ? ogLocale : ''}${routeMeta}${baseMeta}${prerenderedMeta}<meta name="manifest:router-base-depth" content="${routeDepth}">\n</head>`
2122
+ );
2056
2123
  html = markPrerenderedManifestComponents(html);
2057
2124
  mkdirSync(outDir, { recursive: true });
2058
2125
  writeFileSync(outFile, html, 'utf8');
@@ -2113,7 +2180,7 @@ async function runPrerender(config) {
2113
2180
  if (utilMerged.trim()) {
2114
2181
  writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
2115
2182
  process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
2116
- postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css');
2183
+ postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '');
2117
2184
  }
2118
2185
  }
2119
2186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {