mnfst-render 0.1.6 → 0.1.7

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 +250 -9
  2. package/package.json +2 -2
@@ -2,8 +2,9 @@
2
2
 
3
3
  /* Manifest Render */
4
4
 
5
- import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync } from 'node:fs';
6
- import { join, resolve, dirname, relative, basename } from 'node:path';
5
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync, unlinkSync } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { join, resolve, dirname, relative, basename, sep } from 'node:path';
7
8
  import { createServer } from 'node:http';
8
9
  import { createRequire } from 'node:module';
9
10
  import { fileURLToPath } from 'node:url';
@@ -179,6 +180,15 @@ function conditionsToPaths(conditions) {
179
180
  return paths;
180
181
  }
181
182
 
183
+ function getWildcardBasesFromConditions(conditions) {
184
+ const bases = new Set();
185
+ for (const c of conditions) {
186
+ const parsed = normalizeRouteCondition(c);
187
+ if (parsed.kind === 'wildcard-prefix' && parsed.path) bases.add(parsed.path);
188
+ }
189
+ return [...bases];
190
+ }
191
+
182
192
  // --- Discovery: data-driven paths (docs-style YAML group/items[].path) ------
183
193
 
184
194
  function parseYamlPaths(filePath) {
@@ -288,20 +298,38 @@ function parseCsvPaths(filePath) {
288
298
  return paths;
289
299
  }
290
300
 
291
- function discoverDataPaths(manifest, rootDir) {
301
+ function discoverDataPaths(manifest, rootDir, wildcardBases = [], locales = []) {
292
302
  const paths = new Set();
293
303
  const data = manifest.data;
294
304
  if (!data || typeof data !== 'object') return paths;
305
+ const localeSet = new Set((locales || []).map((l) => String(l).toLowerCase()));
306
+
307
+ function shouldIncludeDataPath(rawPath) {
308
+ const p = String(rawPath || '').replace(/^\/+|\/+$/g, '');
309
+ if (!p || p.includes('#') || p.includes('?') || p.includes('*')) return false;
310
+ if (wildcardBases.length === 0) return true;
311
+ const segs = p.split('/');
312
+ const rest = segs.length > 1 && localeSet.has(segs[0].toLowerCase()) ? segs.slice(1).join('/') : p;
313
+ return wildcardBases.some((base) => rest.startsWith(base + '/'));
314
+ }
295
315
 
296
316
  function addFilePaths(value) {
297
317
  if (typeof value !== 'string' || !value.startsWith('/')) return;
298
318
  const filePath = join(rootDir, value.slice(1));
299
319
  if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
300
- parseYamlPaths(filePath).forEach((p) => paths.add('/' + p));
320
+ parseYamlPaths(filePath).forEach((p) => {
321
+ if (shouldIncludeDataPath(p)) paths.add('/' + p);
322
+ });
301
323
  } else if (filePath.endsWith('.json')) {
302
- parseJsonPaths(filePath).forEach((p) => paths.add(p.startsWith('/') ? p : '/' + p));
324
+ parseJsonPaths(filePath).forEach((p) => {
325
+ const normalized = p.startsWith('/') ? p.slice(1) : p;
326
+ if (shouldIncludeDataPath(normalized)) paths.add('/' + normalized);
327
+ });
303
328
  } else if (filePath.endsWith('.csv')) {
304
- parseCsvPaths(filePath).forEach((p) => paths.add(p.startsWith('/') ? p : '/' + p));
329
+ parseCsvPaths(filePath).forEach((p) => {
330
+ const normalized = p.startsWith('/') ? p.slice(1) : p;
331
+ if (shouldIncludeDataPath(normalized)) paths.add('/' + normalized);
332
+ });
305
333
  }
306
334
  }
307
335
 
@@ -321,11 +349,14 @@ function discoverDataPaths(manifest, rootDir) {
321
349
  function discoverRoutes(manifest, rootDir) {
322
350
  const pathSet = new Set();
323
351
  pathSet.add('/');
352
+ const allConditions = new Set();
353
+ const locales = discoverLocales(manifest, rootDir);
324
354
 
325
355
  const indexPath = join(rootDir, 'index.html');
326
356
  if (existsSync(indexPath)) {
327
357
  const indexHtml = readFileSync(indexPath, 'utf8');
328
358
  const conditions = extractXRouteConditions(indexHtml);
359
+ conditions.forEach((c) => allConditions.add(c));
329
360
  conditionsToPaths(conditions).forEach((p) => pathSet.add(p));
330
361
  }
331
362
 
@@ -338,11 +369,13 @@ function discoverRoutes(manifest, rootDir) {
338
369
  if (existsSync(compPath)) {
339
370
  const html = readFileSync(compPath, 'utf8');
340
371
  const conditions = extractXRouteConditions(html);
372
+ conditions.forEach((c) => allConditions.add(c));
341
373
  conditionsToPaths(conditions).forEach((p) => pathSet.add(p));
342
374
  }
343
375
  }
344
376
 
345
- discoverDataPaths(manifest, rootDir).forEach((p) => pathSet.add(p));
377
+ const wildcardBases = getWildcardBasesFromConditions(allConditions);
378
+ discoverDataPaths(manifest, rootDir, wildcardBases, locales).forEach((p) => pathSet.add(p));
346
379
 
347
380
  const arr = [...pathSet].map((p) => (p === '/' ? '' : p.replace(/^\//, '').replace(/\/$/, '') || ''));
348
381
  return arr.includes('') ? arr : ['', ...arr.filter(Boolean)];
@@ -399,6 +432,181 @@ function stripInjectedPluginScripts(html) {
399
432
  return out;
400
433
  }
401
434
 
435
+ function stripRuntimeTailwindArtifacts(html) {
436
+ let out = html.replace(/\sdata-tailwind(?:=(["']).*?\1)?/gi, '');
437
+ // Remove PlayCDN-injected runtime Tailwind stylesheet from snapshots.
438
+ out = out.replace(/<style>\s*\/\*!\s*tailwindcss[\s\S]*?<\/style>/gi, '');
439
+ return out;
440
+ }
441
+
442
+ /** Manifest utilities plugin: <style id="utility-styles"> and <style id="utility-styles-critical"> */
443
+ function extractUtilityStyleBlocks(html) {
444
+ const blocks = [];
445
+ let out = html.replace(
446
+ /<style[^>]*\bid=["']utility-styles-critical["'][^>]*>([\s\S]*?)<\/style>/gi,
447
+ (_, css) => {
448
+ const t = (css || '').trim();
449
+ if (t) blocks.push({ kind: 'critical', css: t });
450
+ return '';
451
+ }
452
+ );
453
+ out = out.replace(/<style[^>]*\bid=["']utility-styles["'][^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
454
+ const t = (css || '').trim();
455
+ if (t) blocks.push({ kind: 'main', css: t });
456
+ return '';
457
+ });
458
+ return { html: out, blocks };
459
+ }
460
+
461
+ function injectAfterHeadOpen(html, snippet) {
462
+ if (!snippet) return html;
463
+ const hrefMatch = snippet.match(/href=["']([^"']+)["']/);
464
+ if (hrefMatch && html.includes(hrefMatch[1])) return html;
465
+ return html.replace(/<head([^>]*)>/i, `<head$1>\n${snippet}\n`);
466
+ }
467
+
468
+ function indexHtmlUsesTailwind(rootDir) {
469
+ const indexPath = join(rootDir, 'index.html');
470
+ if (!existsSync(indexPath)) return false;
471
+ const html = readFileSync(indexPath, 'utf8');
472
+ return /\sdata-tailwind(?:=(["']).*?\1)?/i.test(html) && /<script[^>]*manifest\.min\.js/i.test(html);
473
+ }
474
+
475
+ /**
476
+ * Build a static Tailwind stylesheet via @tailwindcss/cli (v4+), scanning project sources.
477
+ * Only runs when the project opts in (data-tailwind on manifest script) or manifest.prerender.tailwind === true.
478
+ */
479
+ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
480
+ const explicit = pre?.tailwind;
481
+ if (explicit === false) return false;
482
+ const usesTailwind = explicit === true || indexHtmlUsesTailwind(rootDir);
483
+ if (!usesTailwind) return false;
484
+
485
+ const outCss = join(outputDir, 'prerender.tailwind.css');
486
+ let inputPath = null;
487
+ let createdTempInput = false;
488
+ const userInput = pre?.tailwindInput;
489
+ if (typeof userInput === 'string' && userInput.trim()) {
490
+ inputPath = resolve(rootDir, userInput.trim());
491
+ }
492
+ if (!inputPath || !existsSync(inputPath)) {
493
+ inputPath = join(rootDir, '.mnfst-prerender-tailwind-input.css');
494
+ writeFileSync(inputPath, '@import "tailwindcss";\n', 'utf8');
495
+ createdTempInput = true;
496
+ }
497
+
498
+ const outputBasename = basename(outputDir);
499
+ const defaultContent = [
500
+ '**/*.html',
501
+ '**/*.{js,mjs,css}',
502
+ '**/*.json',
503
+ '!**/node_modules/**',
504
+ `!**/${outputBasename}/**`,
505
+ ];
506
+ const contentGlobs = Array.isArray(pre?.tailwindContent) && pre.tailwindContent.length > 0
507
+ ? pre.tailwindContent
508
+ : defaultContent;
509
+
510
+ const args = [
511
+ '--yes',
512
+ '@tailwindcss/cli@4',
513
+ '-i',
514
+ inputPath,
515
+ '-o',
516
+ outCss,
517
+ ];
518
+ for (const g of contentGlobs) {
519
+ args.push('--content', g);
520
+ }
521
+
522
+ process.stdout.write('prerender: compiling Tailwind CSS (this may take a minute)...\n');
523
+ const r = spawnSync('npx', args, {
524
+ cwd: rootDir,
525
+ encoding: 'utf8',
526
+ shell: process.platform === 'win32',
527
+ });
528
+ if (createdTempInput) {
529
+ try {
530
+ unlinkSync(inputPath);
531
+ } catch {
532
+ // ignore
533
+ }
534
+ }
535
+ if (r.status !== 0) {
536
+ console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or fix tailwindInput/tailwindContent in manifest.prerender.');
537
+ if (r.stderr) console.error(r.stderr);
538
+ if (r.stdout) console.error(r.stdout);
539
+ return false;
540
+ }
541
+ if (!existsSync(outCss)) {
542
+ console.error('prerender: Tailwind CLI did not produce prerender.tailwind.css');
543
+ return false;
544
+ }
545
+ process.stdout.write(`prerender: wrote ${relative(rootDir, outCss)}\n`);
546
+ return true;
547
+ }
548
+
549
+ function mergeUtilityCssBlocks(allBlocks) {
550
+ const critical = [];
551
+ const main = [];
552
+ const seenC = new Set();
553
+ const seenM = new Set();
554
+ for (const b of allBlocks) {
555
+ if (b.kind === 'critical') {
556
+ if (!seenC.has(b.css)) {
557
+ seenC.add(b.css);
558
+ critical.push(b.css);
559
+ }
560
+ } else {
561
+ if (!seenM.has(b.css)) {
562
+ seenM.add(b.css);
563
+ main.push(b.css);
564
+ }
565
+ }
566
+ }
567
+ const parts = [];
568
+ if (critical.length) parts.push('/* manifest utilities: critical */\n', critical.join('\n\n'));
569
+ if (main.length) parts.push('/* manifest utilities */\n', main.join('\n\n'));
570
+ return parts.join('\n');
571
+ }
572
+
573
+ function walkHtmlFiles(dir, out = []) {
574
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
575
+ if (ent.name.startsWith('.')) continue;
576
+ const p = join(dir, ent.name);
577
+ if (ent.isDirectory()) {
578
+ if (ent.name === 'node_modules') continue;
579
+ walkHtmlFiles(p, out);
580
+ } else if (ent.name.endsWith('.html')) out.push(p);
581
+ }
582
+ return out;
583
+ }
584
+
585
+ function depthFromOutputRoot(outputDir, filePath) {
586
+ const rel = relative(outputDir, dirname(filePath));
587
+ if (!rel || rel === '.') return 0;
588
+ return rel.split(sep).filter(Boolean).length;
589
+ }
590
+
591
+ /** Inject stylesheet link with correct relative href for static hosting (after prerender wrote files). */
592
+ function postProcessInjectStylesheetLink(outputDir, filename) {
593
+ const cssPath = join(outputDir, filename);
594
+ if (!existsSync(cssPath)) return;
595
+ const stat = statSync(cssPath);
596
+ if (stat.size === 0) return;
597
+
598
+ const files = walkHtmlFiles(outputDir);
599
+ for (const file of files) {
600
+ let html = readFileSync(file, 'utf8');
601
+ if (html.includes(filename)) continue;
602
+ const depth = depthFromOutputRoot(outputDir, file);
603
+ const prefix = depth ? '../'.repeat(depth) : '';
604
+ const tag = `<link rel="stylesheet" href="${prefix}${filename}">`;
605
+ html = injectAfterHeadOpen(html, tag);
606
+ writeFileSync(file, html, 'utf8');
607
+ }
608
+ }
609
+
402
610
  // --- (Removed) We used to strip x-text containing product. / feature. to avoid wrong-scope errors
403
611
  // on duplicated x-for output, but that also stripped legitimate loop body bindings (e.g. product
404
612
  // search results), breaking reactivity. If "product/feature is not defined" appears again, fix
@@ -820,6 +1028,7 @@ function copyProjectIntoDist(rootResolved, outputResolved) {
820
1028
 
821
1029
  async function main() {
822
1030
  const config = resolveConfig();
1031
+ const startedAt = Date.now();
823
1032
  let staticServer = null;
824
1033
  if (config.serve) {
825
1034
  const { server, url } = await startStaticServer(config.root);
@@ -833,6 +1042,8 @@ async function main() {
833
1042
  await new Promise((res) => staticServer.close(res));
834
1043
  }
835
1044
  }
1045
+ const secs = ((Date.now() - startedAt) / 1000).toFixed(1);
1046
+ process.stdout.write(`prerender: total time ${secs}s\n`);
836
1047
  }
837
1048
 
838
1049
  async function runPrerender(config) {
@@ -851,6 +1062,12 @@ async function runPrerender(config) {
851
1062
 
852
1063
  const defaultLocale = locales[0] ?? null;
853
1064
  const routeSegments = discoverRoutes(manifest, config.root);
1065
+ const localeSet = new Set(locales.map((l) => String(l).toLowerCase()));
1066
+ const localeNeutralSegments = routeSegments.filter((seg) => {
1067
+ if (!seg) return true;
1068
+ const first = seg.split('/')[0].toLowerCase();
1069
+ return !localeSet.has(first);
1070
+ });
854
1071
  const paths = new Set();
855
1072
  paths.add('');
856
1073
 
@@ -859,14 +1076,15 @@ async function runPrerender(config) {
859
1076
  }
860
1077
  for (const locale of locales.slice(1)) {
861
1078
  paths.add(locale);
862
- for (const seg of routeSegments) {
1079
+ for (const seg of localeNeutralSegments) {
1080
+ if (!seg) continue;
863
1081
  paths.add(`${locale}/${seg}`);
864
1082
  }
865
1083
  }
866
1084
  // Default locale also under its slug (e.g. /en/, /en/page-1) so linking is symmetric; canonical points to root
867
1085
  if (defaultLocale) {
868
1086
  paths.add(defaultLocale);
869
- for (const seg of routeSegments) {
1087
+ for (const seg of localeNeutralSegments) {
870
1088
  if (seg !== '') paths.add(`${defaultLocale}/${seg}`);
871
1089
  }
872
1090
  }
@@ -895,6 +1113,11 @@ async function runPrerender(config) {
895
1113
  mkdirSync(outputResolved, { recursive: true });
896
1114
  copyProjectIntoDist(rootResolved, outputResolved);
897
1115
 
1116
+ const pre = manifest.prerender ?? {};
1117
+ const bundleUtilities = pre.utilitiesBundle !== false;
1118
+ const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved, pre);
1119
+ const utilityBlocks = [];
1120
+
898
1121
  let browser;
899
1122
  try {
900
1123
  const chromium = await importFromProject('@sparticuz/chromium');
@@ -1131,6 +1354,15 @@ async function runPrerender(config) {
1131
1354
  let html = await page.evaluate(() => document.documentElement.outerHTML);
1132
1355
  html = stripDevOnlyContent(html);
1133
1356
  html = stripInjectedPluginScripts(html);
1357
+ html = stripRuntimeTailwindArtifacts(html);
1358
+ if (bundleUtilities) {
1359
+ const extracted = extractUtilityStyleBlocks(html);
1360
+ html = extracted.html;
1361
+ for (const b of extracted.blocks) utilityBlocks.push(b);
1362
+ }
1363
+ if (tailwindBuilt) {
1364
+ html = injectAfterHeadOpen(html, '<link rel="stylesheet" href="/prerender.tailwind.css">');
1365
+ }
1134
1366
  html = stripDuplicatedLoopDirectives(html);
1135
1367
  html = stripPrerenderedXDataDirectives(html);
1136
1368
  const currentLocale =
@@ -1178,6 +1410,15 @@ async function runPrerender(config) {
1178
1410
  await browser.close();
1179
1411
  }
1180
1412
 
1413
+ if (bundleUtilities) {
1414
+ const utilMerged = mergeUtilityCssBlocks(utilityBlocks);
1415
+ if (utilMerged.trim()) {
1416
+ writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
1417
+ process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
1418
+ postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css');
1419
+ }
1420
+ }
1421
+
1181
1422
  writeSeoFiles(
1182
1423
  config.output,
1183
1424
  pathList.filter((p) => p !== NOT_FOUND_PATH),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,4 +32,4 @@
32
32
  "url": "git+https://github.com/andrewmatlock/Manifest.git",
33
33
  "directory": "packages/render"
34
34
  }
35
- }
35
+ }