mnfst-render 0.1.6 → 0.1.8

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 +294 -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, readSync, 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,223 @@ 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
+ function promptContinueWithRuntimeTailwind(rootDir) {
476
+ const installMsg = [
477
+ 'prerender: tailwindcss package is not installed for this project.',
478
+ '',
479
+ 'To enable static Tailwind CSS compilation, install:',
480
+ ' npm i -D tailwindcss @tailwindcss/cli',
481
+ '',
482
+ `Project: ${rootDir}`,
483
+ '',
484
+ 'Continue prerender with runtime data-tailwind instead? [P]roceed/[E]nd (default: P): ',
485
+ ].join('\n');
486
+ process.stdout.write(`${installMsg}\n`);
487
+
488
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
489
+ process.stdout.write(
490
+ 'prerender: non-interactive terminal detected; continuing with runtime data-tailwind behavior.\n'
491
+ );
492
+ return true;
493
+ }
494
+ const buf = Buffer.alloc(1);
495
+ let answer = '';
496
+ while (true) {
497
+ const n = readSync(0, buf, 0, 1, null);
498
+ if (n <= 0) break;
499
+ const ch = buf.toString('utf8', 0, n);
500
+ if (ch === '\n' || ch === '\r') break;
501
+ answer += ch;
502
+ }
503
+ const normalized = answer.trim().toLowerCase();
504
+ return normalized === '' || normalized === 'p' || normalized === 'proceed' || normalized === 'y' || normalized === 'yes';
505
+ }
506
+
507
+ /**
508
+ * Build a static Tailwind stylesheet via @tailwindcss/cli (v4+), scanning project sources.
509
+ * Only runs when the project opts in (data-tailwind on manifest script) or manifest.prerender.tailwind === true.
510
+ */
511
+ function runTailwindCliForPrerender(rootDir, outputDir, pre) {
512
+ const explicit = pre?.tailwind;
513
+ if (explicit === false) return false;
514
+ const usesTailwind = explicit === true || indexHtmlUsesTailwind(rootDir);
515
+ if (!usesTailwind) return false;
516
+
517
+ const outCss = join(outputDir, 'prerender.tailwind.css');
518
+ try {
519
+ require.resolve('tailwindcss', { paths: [rootDir] });
520
+ } catch {
521
+ const proceed = promptContinueWithRuntimeTailwind(rootDir);
522
+ if (!proceed) {
523
+ throw new Error('prerender aborted: install tailwindcss/@tailwindcss/cli or disable prerender.tailwind.');
524
+ }
525
+ process.stdout.write('prerender: continuing with runtime data-tailwind behavior.\n');
526
+ return false;
527
+ }
528
+ let inputPath = null;
529
+ let createdTempInput = false;
530
+ const userInput = pre?.tailwindInput;
531
+ if (typeof userInput === 'string' && userInput.trim()) {
532
+ inputPath = resolve(rootDir, userInput.trim());
533
+ }
534
+ if (!inputPath || !existsSync(inputPath)) {
535
+ inputPath = join(rootDir, '.mnfst-prerender-tailwind-input.css');
536
+ writeFileSync(inputPath, '@import "tailwindcss";\n', 'utf8');
537
+ createdTempInput = true;
538
+ }
539
+
540
+ const outputBasename = basename(outputDir);
541
+ const defaultContent = [
542
+ '**/*.html',
543
+ '**/*.{js,mjs,css}',
544
+ '**/*.json',
545
+ '!**/node_modules/**',
546
+ `!**/${outputBasename}/**`,
547
+ ];
548
+ const contentGlobs = Array.isArray(pre?.tailwindContent) && pre.tailwindContent.length > 0
549
+ ? pre.tailwindContent
550
+ : defaultContent;
551
+
552
+ const args = [
553
+ '--yes',
554
+ '@tailwindcss/cli@4',
555
+ '-i',
556
+ inputPath,
557
+ '-o',
558
+ outCss,
559
+ ];
560
+ for (const g of contentGlobs) {
561
+ args.push('--content', g);
562
+ }
563
+
564
+ process.stdout.write('prerender: compiling Tailwind CSS (this may take a minute)...\n');
565
+ const r = spawnSync('npx', args, {
566
+ cwd: rootDir,
567
+ encoding: 'utf8',
568
+ shell: process.platform === 'win32',
569
+ });
570
+ if (createdTempInput) {
571
+ try {
572
+ unlinkSync(inputPath);
573
+ } catch {
574
+ // ignore
575
+ }
576
+ }
577
+ if (r.status !== 0) {
578
+ console.error('prerender: Tailwind CLI failed; install with `npm i -D tailwindcss @tailwindcss/cli` or fix tailwindInput/tailwindContent in manifest.prerender.');
579
+ if (r.stderr) console.error(r.stderr);
580
+ if (r.stdout) console.error(r.stdout);
581
+ return false;
582
+ }
583
+ if (!existsSync(outCss)) {
584
+ console.error('prerender: Tailwind CLI did not produce prerender.tailwind.css');
585
+ return false;
586
+ }
587
+ process.stdout.write(`prerender: wrote ${relative(rootDir, outCss)}\n`);
588
+ return true;
589
+ }
590
+
591
+ function mergeUtilityCssBlocks(allBlocks) {
592
+ const critical = [];
593
+ const main = [];
594
+ const seenC = new Set();
595
+ const seenM = new Set();
596
+ for (const b of allBlocks) {
597
+ if (b.kind === 'critical') {
598
+ if (!seenC.has(b.css)) {
599
+ seenC.add(b.css);
600
+ critical.push(b.css);
601
+ }
602
+ } else {
603
+ if (!seenM.has(b.css)) {
604
+ seenM.add(b.css);
605
+ main.push(b.css);
606
+ }
607
+ }
608
+ }
609
+ const parts = [];
610
+ if (critical.length) parts.push('/* manifest utilities: critical */\n', critical.join('\n\n'));
611
+ if (main.length) parts.push('/* manifest utilities */\n', main.join('\n\n'));
612
+ return parts.join('\n');
613
+ }
614
+
615
+ function walkHtmlFiles(dir, out = []) {
616
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
617
+ if (ent.name.startsWith('.')) continue;
618
+ const p = join(dir, ent.name);
619
+ if (ent.isDirectory()) {
620
+ if (ent.name === 'node_modules') continue;
621
+ walkHtmlFiles(p, out);
622
+ } else if (ent.name.endsWith('.html')) out.push(p);
623
+ }
624
+ return out;
625
+ }
626
+
627
+ function depthFromOutputRoot(outputDir, filePath) {
628
+ const rel = relative(outputDir, dirname(filePath));
629
+ if (!rel || rel === '.') return 0;
630
+ return rel.split(sep).filter(Boolean).length;
631
+ }
632
+
633
+ /** Inject stylesheet link with correct relative href for static hosting (after prerender wrote files). */
634
+ function postProcessInjectStylesheetLink(outputDir, filename) {
635
+ const cssPath = join(outputDir, filename);
636
+ if (!existsSync(cssPath)) return;
637
+ const stat = statSync(cssPath);
638
+ if (stat.size === 0) return;
639
+
640
+ const files = walkHtmlFiles(outputDir);
641
+ for (const file of files) {
642
+ let html = readFileSync(file, 'utf8');
643
+ if (html.includes(filename)) continue;
644
+ const depth = depthFromOutputRoot(outputDir, file);
645
+ const prefix = depth ? '../'.repeat(depth) : '';
646
+ const tag = `<link rel="stylesheet" href="${prefix}${filename}">`;
647
+ html = injectAfterHeadOpen(html, tag);
648
+ writeFileSync(file, html, 'utf8');
649
+ }
650
+ }
651
+
402
652
  // --- (Removed) We used to strip x-text containing product. / feature. to avoid wrong-scope errors
403
653
  // on duplicated x-for output, but that also stripped legitimate loop body bindings (e.g. product
404
654
  // search results), breaking reactivity. If "product/feature is not defined" appears again, fix
@@ -820,6 +1070,7 @@ function copyProjectIntoDist(rootResolved, outputResolved) {
820
1070
 
821
1071
  async function main() {
822
1072
  const config = resolveConfig();
1073
+ const startedAt = Date.now();
823
1074
  let staticServer = null;
824
1075
  if (config.serve) {
825
1076
  const { server, url } = await startStaticServer(config.root);
@@ -833,6 +1084,8 @@ async function main() {
833
1084
  await new Promise((res) => staticServer.close(res));
834
1085
  }
835
1086
  }
1087
+ const secs = ((Date.now() - startedAt) / 1000).toFixed(1);
1088
+ process.stdout.write(`prerender: total time ${secs}s\n`);
836
1089
  }
837
1090
 
838
1091
  async function runPrerender(config) {
@@ -851,6 +1104,12 @@ async function runPrerender(config) {
851
1104
 
852
1105
  const defaultLocale = locales[0] ?? null;
853
1106
  const routeSegments = discoverRoutes(manifest, config.root);
1107
+ const localeSet = new Set(locales.map((l) => String(l).toLowerCase()));
1108
+ const localeNeutralSegments = routeSegments.filter((seg) => {
1109
+ if (!seg) return true;
1110
+ const first = seg.split('/')[0].toLowerCase();
1111
+ return !localeSet.has(first);
1112
+ });
854
1113
  const paths = new Set();
855
1114
  paths.add('');
856
1115
 
@@ -859,14 +1118,15 @@ async function runPrerender(config) {
859
1118
  }
860
1119
  for (const locale of locales.slice(1)) {
861
1120
  paths.add(locale);
862
- for (const seg of routeSegments) {
1121
+ for (const seg of localeNeutralSegments) {
1122
+ if (!seg) continue;
863
1123
  paths.add(`${locale}/${seg}`);
864
1124
  }
865
1125
  }
866
1126
  // Default locale also under its slug (e.g. /en/, /en/page-1) so linking is symmetric; canonical points to root
867
1127
  if (defaultLocale) {
868
1128
  paths.add(defaultLocale);
869
- for (const seg of routeSegments) {
1129
+ for (const seg of localeNeutralSegments) {
870
1130
  if (seg !== '') paths.add(`${defaultLocale}/${seg}`);
871
1131
  }
872
1132
  }
@@ -895,6 +1155,11 @@ async function runPrerender(config) {
895
1155
  mkdirSync(outputResolved, { recursive: true });
896
1156
  copyProjectIntoDist(rootResolved, outputResolved);
897
1157
 
1158
+ const pre = manifest.prerender ?? {};
1159
+ const bundleUtilities = pre.utilitiesBundle !== false;
1160
+ const tailwindBuilt = runTailwindCliForPrerender(rootResolved, outputResolved, pre);
1161
+ const utilityBlocks = [];
1162
+
898
1163
  let browser;
899
1164
  try {
900
1165
  const chromium = await importFromProject('@sparticuz/chromium');
@@ -1131,6 +1396,17 @@ async function runPrerender(config) {
1131
1396
  let html = await page.evaluate(() => document.documentElement.outerHTML);
1132
1397
  html = stripDevOnlyContent(html);
1133
1398
  html = stripInjectedPluginScripts(html);
1399
+ if (tailwindBuilt) {
1400
+ html = stripRuntimeTailwindArtifacts(html);
1401
+ }
1402
+ if (bundleUtilities) {
1403
+ const extracted = extractUtilityStyleBlocks(html);
1404
+ html = extracted.html;
1405
+ for (const b of extracted.blocks) utilityBlocks.push(b);
1406
+ }
1407
+ if (tailwindBuilt) {
1408
+ html = injectAfterHeadOpen(html, '<link rel="stylesheet" href="/prerender.tailwind.css">');
1409
+ }
1134
1410
  html = stripDuplicatedLoopDirectives(html);
1135
1411
  html = stripPrerenderedXDataDirectives(html);
1136
1412
  const currentLocale =
@@ -1178,6 +1454,15 @@ async function runPrerender(config) {
1178
1454
  await browser.close();
1179
1455
  }
1180
1456
 
1457
+ if (bundleUtilities) {
1458
+ const utilMerged = mergeUtilityCssBlocks(utilityBlocks);
1459
+ if (utilMerged.trim()) {
1460
+ writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
1461
+ process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
1462
+ postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css');
1463
+ }
1464
+ }
1465
+
1181
1466
  writeSeoFiles(
1182
1467
  config.output,
1183
1468
  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.8",
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
+ }