mnfst-render 0.5.26 → 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.
Files changed (2) hide show
  1. package/manifest.render.mjs +204 -36
  2. package/package.json +1 -1
@@ -254,6 +254,7 @@ function resolveConfig() {
254
254
  serve,
255
255
  output: resolve(root, cli.output ?? pre.output ?? 'website'),
256
256
  root,
257
+ manifest,
257
258
  routerBase: pre.routerBase ?? null,
258
259
  /** Logical path prefixes (after locale) that skip sticky locale prefix; see manifest:locale-route-exclude */
259
260
  localeRouteExclude: normalizeLocaleRouteExclude(
@@ -724,6 +725,18 @@ function stripDataTailwindAttr(html) {
724
725
  return html.replace(/\sdata-tailwind(?:=(["']).*?\1)?/gi, '');
725
726
  }
726
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
+
727
740
  /** Theme class de-bake + synchronous bootstrap.
728
741
  *
729
742
  * Puppeteer applies `<html class="light">` or `<html class="dark">` based on
@@ -1210,7 +1223,8 @@ function stripPrerenderBakedRadioCheckedForXModel(html) {
1210
1223
 
1211
1224
  // --- Canonical and hreflang (per-page injection) ---
1212
1225
 
1213
- function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
1226
+ function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base, opts = {}) {
1227
+ const { skipCanonical = false } = opts;
1214
1228
  const baseClean = base.replace(/\/$/, '');
1215
1229
  const defaultLoc = defaultLocale || locales[0];
1216
1230
  const isDefaultLocalePrefixed =
@@ -1223,7 +1237,10 @@ function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
1223
1237
  : pathSeg;
1224
1238
  const canonicalHref = canonicalPath === '' ? `${baseClean}/` : `${baseClean}/${canonicalPath}`;
1225
1239
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
1226
- let out = `<link rel="canonical" href="${esc(canonicalHref)}">\n`;
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`;
1227
1244
  if (locales.length > 1) {
1228
1245
  const currentLocale = locales.find((l) => pathSeg === l || pathSeg.startsWith(l + '/')) || defaultLoc;
1229
1246
  const logicalRoute =
@@ -1581,7 +1598,8 @@ function generateLocaleVariantHtml({
1581
1598
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
1582
1599
 
1583
1600
  const liveBase = config.liveUrl.replace(/\/$/, '');
1584
- const canonicalHreflang = buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, liveBase);
1601
+ const hasSourceCanonical = /<link\b[^>]*\brel=(["'])\s*canonical\s*\1/i.test(html);
1602
+ const canonicalHreflang = buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, liveBase, { skipCanonical: hasSourceCanonical });
1585
1603
  const ogLocale = buildOgLocale(pathSeg, locales, defaultLocale);
1586
1604
  const injectOgLocale = ogLocale && hasOtherOgMeta(html);
1587
1605
  if (injectOgLocale) html = stripOgLocaleFromHead(html);
@@ -2383,29 +2401,123 @@ function routeHtmlPath(outputDir, pathSeg) {
2383
2401
  }
2384
2402
 
2385
2403
  /**
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.
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.
2390
2411
  */
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)\//, '');
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.
2480
+ */
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)\//, '');
2397
2499
  const candidates = [
2398
2500
  join(rootDir, 'articles', `${stripPrefix}.md`),
2399
- join(rootDir, 'articles', `${pathSeg}.md`),
2400
- join(rootDir, 'pages', `${pathSeg}.html`),
2401
- join(rootDir, `${pathSeg}.md`),
2501
+ join(rootDir, 'articles', `${unlocalizedPath}.md`),
2502
+ join(rootDir, 'pages', `${unlocalizedPath}.html`),
2503
+ join(rootDir, `${unlocalizedPath}.md`),
2402
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;
2403
2513
  for (const c of candidates) {
2404
2514
  try {
2405
2515
  const s = statSync(c);
2406
- if (s.isFile()) return s.mtime.toISOString().slice(0, 10);
2516
+ if (s.isFile() && (!latest || s.mtime > latest)) latest = s.mtime;
2407
2517
  } catch { /* not found */ }
2408
2518
  }
2519
+ if (latest) return latest.toISOString().slice(0, 10);
2520
+
2409
2521
  // Fallback to the prerendered output mtime (always present).
2410
2522
  try {
2411
2523
  const out = routeHtmlPath(outputDir, pathSeg || '');
@@ -2420,6 +2532,7 @@ function writeSeoFiles(outputDir, pathList, liveUrl, locales, defaultLocale, ctx
2420
2532
  const localeList = Array.isArray(locales) ? locales : [];
2421
2533
  const multiLocale = localeList.length > 1;
2422
2534
  const rootDir = ctx.rootDir || '';
2535
+ const manifest = ctx.manifest || null;
2423
2536
 
2424
2537
  writeFileSync(
2425
2538
  join(outputDir, 'robots.txt'),
@@ -2445,7 +2558,7 @@ Sitemap: ${base}/sitemap.xml
2445
2558
  body += `\n <xhtml:link rel="alternate" hreflang="${escapeXmlText(hreflang)}" href="${escapeXmlText(href)}" />`;
2446
2559
  }
2447
2560
  }
2448
- const lastmod = routeLastModDate(rootDir, outputDir, pathSeg);
2561
+ const lastmod = routeLastModDate(rootDir, outputDir, pathSeg, manifest, localeList, defaultLocale);
2449
2562
  body += `\n <lastmod>${lastmod}</lastmod>
2450
2563
  <changefreq>monthly</changefreq>
2451
2564
  <priority>${path === '' ? '1.0' : '0.8'}</priority>`;
@@ -2716,6 +2829,42 @@ function copyProjectIntoDist(rootResolved, outputResolved) {
2716
2829
  COPY_EXCLUDE.delete(outputDirName);
2717
2830
  }
2718
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
+
2719
2868
  // --- Main --------------------------------------------------------------------
2720
2869
 
2721
2870
  async function main() {
@@ -2819,6 +2968,14 @@ async function runPrerender(config) {
2819
2968
  }
2820
2969
  mkdirSync(outputResolved, { recursive: true });
2821
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
+ }
2822
2979
 
2823
2980
  const pre = manifest.prerender ?? {};
2824
2981
  const bundleUtilities = pre.utilitiesBundle !== false;
@@ -3753,11 +3910,20 @@ async function runPrerender(config) {
3753
3910
  await page.evaluate(() => {
3754
3911
  const A = window.Alpine;
3755
3912
  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+/;
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
+ };
3757
3923
  // Include x-init: expanded clones still had x-init="getDescription(article)" etc.; Alpine then throws (article undefined).
3758
3924
  const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-init|x-icon|x-on:|@)/;
3759
3925
  const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
3760
- const stripLoopBindings = (el, itemVar, indexVar) => {
3926
+ const stripLoopBindings = (el, loopVars) => {
3761
3927
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
3762
3928
  for (const node of nodes) {
3763
3929
  // Skip elements inside data-hydrate islands — their bindings must remain live
@@ -3766,7 +3932,7 @@ async function runPrerender(config) {
3766
3932
  for (const attr of attrs) {
3767
3933
  if (!bindingAttrRegex.test(attr.name)) continue;
3768
3934
  const expr = attr.value || '';
3769
- if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) {
3935
+ if (loopVars.some((v) => hasVar(expr, v))) {
3770
3936
  const name = attr.name;
3771
3937
  if (name === 'x-text' || name === 'x-html') {
3772
3938
  if ((node.textContent || '').trim() || (node.innerHTML || '').trim()) {
@@ -3808,10 +3974,8 @@ async function runPrerender(config) {
3808
3974
  document.querySelectorAll('template[x-for]').forEach((tpl) => {
3809
3975
  if (tpl.hasAttribute('data-hydrate') || tpl.closest('[data-hydrate]')) return;
3810
3976
  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;
3977
+ const loopVars = extractLoopVars(xFor);
3978
+ if (!loopVars.length) return;
3815
3979
 
3816
3980
  const first = tpl.content?.firstElementChild;
3817
3981
  if (!first) return;
@@ -3820,7 +3984,7 @@ async function runPrerender(config) {
3820
3984
  let next = tpl.nextElementSibling;
3821
3985
  while (next) {
3822
3986
  if (next.tagName !== tag) break;
3823
- stripLoopBindings(next, itemVar, indexVar);
3987
+ stripLoopBindings(next, loopVars);
3824
3988
  next = next.nextElementSibling;
3825
3989
  }
3826
3990
  });
@@ -3914,10 +4078,13 @@ async function runPrerender(config) {
3914
4078
  // Remove orphan x-for clones that still reference loop-scope vars (e.g. image/index)
3915
4079
  // outside their template scope. These throw Alpine errors in live static hosting.
3916
4080
  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+/;
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
+ };
3918
4085
  const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-init|x-icon|x-on:|@)/;
3919
4086
  const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
3920
- const elementReferencesLoopScope = (el, itemVar, indexVar) => {
4087
+ const elementReferencesLoopScope = (el, loopVars) => {
3921
4088
  if (!el) return false;
3922
4089
  const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
3923
4090
  for (const node of nodes) {
@@ -3925,7 +4092,7 @@ async function runPrerender(config) {
3925
4092
  for (const attr of attrs) {
3926
4093
  if (!bindingAttrRegex.test(attr.name)) continue;
3927
4094
  const expr = attr.value || '';
3928
- if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) return true;
4095
+ if (loopVars.some((v) => hasVar(expr, v))) return true;
3929
4096
  }
3930
4097
  }
3931
4098
  return false;
@@ -3935,10 +4102,8 @@ async function runPrerender(config) {
3935
4102
  // Running this on all x-for templates can remove valid prerendered list items.
3936
4103
  document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
3937
4104
  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;
4105
+ const loopVars = extractLoopVars(xFor);
4106
+ if (!loopVars.length) return;
3942
4107
 
3943
4108
  const first = tpl.content?.firstElementChild;
3944
4109
  if (!first) return;
@@ -3949,7 +4114,7 @@ async function runPrerender(config) {
3949
4114
  const sameTag = next.tagName === tag;
3950
4115
  if (!sameTag) break;
3951
4116
 
3952
- const referencesLoopScope = elementReferencesLoopScope(next, itemVar, indexVar);
4117
+ const referencesLoopScope = elementReferencesLoopScope(next, loopVars);
3953
4118
 
3954
4119
  const toRemove = next;
3955
4120
  next = next.nextElementSibling;
@@ -4072,7 +4237,8 @@ async function runPrerender(config) {
4072
4237
 
4073
4238
  html = rewriteHtmlAssetPaths(html, fileSegments.length);
4074
4239
  const liveBase = config.liveUrl.replace(/\/$/, '');
4075
- const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase);
4240
+ const hasSourceCanonical = /<link\b[^>]*\brel=(["'])\s*canonical\s*\1/i.test(html);
4241
+ const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase, { skipCanonical: hasSourceCanonical });
4076
4242
  const ogLocale = buildOgLocale(is404 ? '' : pathSeg, locales, defaultLocale);
4077
4243
  const injectOgLocale = ogLocale && hasOtherOgMeta(html);
4078
4244
  if (injectOgLocale) html = stripOgLocaleFromHead(html);
@@ -4094,6 +4260,7 @@ async function runPrerender(config) {
4094
4260
  );
4095
4261
  // (Hydration contract was already injected into the raw HTML before
4096
4262
  // the Node.js post-processing pipeline ran, so it's already present.)
4263
+ html = ensureDoctype(html);
4097
4264
  mkdirSync(outDir, { recursive: true });
4098
4265
  writeFileSync(outFile, html, 'utf8');
4099
4266
  pushDebug({
@@ -4276,7 +4443,7 @@ async function runPrerender(config) {
4276
4443
  const fileSegments = pathToFileSegments(pathSeg ? '/' + pathSeg : '/');
4277
4444
  const outDir = join(config.output, ...fileSegments);
4278
4445
  mkdirSync(outDir, { recursive: true });
4279
- writeFileSync(join(outDir, 'index.html'), html, 'utf8');
4446
+ writeFileSync(join(outDir, 'index.html'), ensureDoctype(html), 'utf8');
4280
4447
  } catch (err) {
4281
4448
  failedPaths.push({ path: displayPath, message: err?.message ?? String(err) });
4282
4449
  process.stderr.write(`prerender: substitution failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
@@ -4317,6 +4484,7 @@ async function runPrerender(config) {
4317
4484
  defaultLocale,
4318
4485
  {
4319
4486
  rootDir: config.root,
4487
+ manifest: config.manifest,
4320
4488
  siteName: config.seo?.siteName,
4321
4489
  siteDescription: config.seo?.siteDescription,
4322
4490
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.26",
3
+ "version": "0.5.27",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {