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.
- package/manifest.render.mjs +250 -9
- package/package.json +2 -2
package/manifest.render.mjs
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|