mnfst-render 0.5.25 → 0.5.27

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