mnfst-render 0.1.4 → 0.1.6
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 +143 -21
- package/package.json +1 -1
package/manifest.render.mjs
CHANGED
|
@@ -144,16 +144,37 @@ function extractXRouteConditions(html) {
|
|
|
144
144
|
return conditions;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
function normalizeRouteCondition(cond) {
|
|
148
|
+
const raw = String(cond || '').trim();
|
|
149
|
+
if (!raw) return { kind: 'all', path: '' };
|
|
150
|
+
if (raw.startsWith('!')) {
|
|
151
|
+
const omitted = raw.slice(1).trim();
|
|
152
|
+
if (!omitted || omitted === '*') return { kind: 'not-found', path: '' }; // !*
|
|
153
|
+
return { kind: 'omit', path: omitted };
|
|
154
|
+
}
|
|
155
|
+
if (raw === '*') return { kind: 'all', path: '' };
|
|
156
|
+
const withoutExact = raw.startsWith('=') ? raw.slice(1) : raw;
|
|
157
|
+
const trimmed = withoutExact.replace(/^\/+|\/+$/g, '');
|
|
158
|
+
if (!trimmed) return { kind: 'root', path: '' };
|
|
159
|
+
if (trimmed.endsWith('/*')) {
|
|
160
|
+
const base = trimmed.slice(0, -2).replace(/^\/+|\/+$/g, '');
|
|
161
|
+
return base ? { kind: 'wildcard-prefix', path: base } : { kind: 'all', path: '' };
|
|
162
|
+
}
|
|
163
|
+
if (trimmed.includes('*')) return { kind: 'unsupported-pattern', path: trimmed };
|
|
164
|
+
return { kind: 'path', path: trimmed };
|
|
165
|
+
}
|
|
166
|
+
|
|
147
167
|
function conditionsToPaths(conditions) {
|
|
148
168
|
const paths = new Set();
|
|
149
169
|
paths.add('/');
|
|
150
170
|
for (const c of conditions) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
const parsed = normalizeRouteCondition(c);
|
|
172
|
+
// Discovery rules aligned with router docs:
|
|
173
|
+
// - "*" and omitted routes do not define concrete paths.
|
|
174
|
+
// - "!*" is handled separately via explicit NOT_FOUND path.
|
|
175
|
+
// - "about/*" does not include "/about" by itself; concrete children come from data paths.
|
|
176
|
+
if (parsed.kind === 'path') paths.add('/' + parsed.path);
|
|
177
|
+
else if (parsed.kind === 'root') paths.add('/');
|
|
157
178
|
}
|
|
158
179
|
return paths;
|
|
159
180
|
}
|
|
@@ -177,6 +198,13 @@ function parseYamlPaths(filePath) {
|
|
|
177
198
|
const segment = pathMatch[1].trim();
|
|
178
199
|
paths.push(`${currentGroup}/${segment}`);
|
|
179
200
|
}
|
|
201
|
+
const genericPathMatch = line.match(/^\s*(?:-\s*)?(?:path|slug):\s*["']?([^"'\n#]+)["']?/);
|
|
202
|
+
if (genericPathMatch) {
|
|
203
|
+
const v = genericPathMatch[1].trim().replace(/^\/+|\/+$/g, '');
|
|
204
|
+
if (v && !v.includes('*') && !/\.[a-z0-9]+$/i.test(v)) {
|
|
205
|
+
paths.push(v);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
180
208
|
}
|
|
181
209
|
return paths;
|
|
182
210
|
}
|
|
@@ -309,10 +337,8 @@ function discoverRoutes(manifest, rootDir) {
|
|
|
309
337
|
const compPath = join(rootDir, rel);
|
|
310
338
|
if (existsSync(compPath)) {
|
|
311
339
|
const html = readFileSync(compPath, 'utf8');
|
|
312
|
-
extractXRouteConditions(html)
|
|
313
|
-
|
|
314
|
-
else if (c) pathSet.add('/' + c);
|
|
315
|
-
});
|
|
340
|
+
const conditions = extractXRouteConditions(html);
|
|
341
|
+
conditionsToPaths(conditions).forEach((p) => pathSet.add(p));
|
|
316
342
|
}
|
|
317
343
|
}
|
|
318
344
|
|
|
@@ -329,6 +355,29 @@ function pathToFileSegments(pathname) {
|
|
|
329
355
|
return normalized ? normalized.split('/') : [];
|
|
330
356
|
}
|
|
331
357
|
|
|
358
|
+
function validatePrerenderedOutput(outputDir, pathList) {
|
|
359
|
+
const invalidPathTokens = pathList.filter((p) => /(^|\/)[*=]/.test(p) || p.includes('/*') || p.includes('*'));
|
|
360
|
+
if (invalidPathTokens.length > 0) {
|
|
361
|
+
throw new Error(`prerender validation failed: invalid discovered route token(s): ${invalidPathTokens.join(', ')}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const badFolders = [];
|
|
365
|
+
function walk(dir, rel = '') {
|
|
366
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
367
|
+
for (const ent of entries) {
|
|
368
|
+
if (!ent.isDirectory()) continue;
|
|
369
|
+
const seg = ent.name;
|
|
370
|
+
const nextRel = rel ? `${rel}/${seg}` : seg;
|
|
371
|
+
if (seg.includes('*') || seg.startsWith('=')) badFolders.push(nextRel);
|
|
372
|
+
walk(join(dir, seg), nextRel);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (existsSync(outputDir)) walk(outputDir, '');
|
|
376
|
+
if (badFolders.length > 0) {
|
|
377
|
+
throw new Error(`prerender validation failed: invalid output folder(s): ${badFolders.join(', ')}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
332
381
|
// --- Strip dev-only injected content (e.g. browser-sync) so dist works under any server -
|
|
333
382
|
|
|
334
383
|
function stripDevOnlyContent(html) {
|
|
@@ -464,6 +513,39 @@ function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
|
|
|
464
513
|
return out;
|
|
465
514
|
}
|
|
466
515
|
|
|
516
|
+
/** Same alternate URLs as buildCanonicalAndHreflang; used for sitemap xhtml:link entries. */
|
|
517
|
+
function getAlternateLinksForPath(pathSeg, locales, defaultLocale, base) {
|
|
518
|
+
const baseClean = base.replace(/\/$/, '');
|
|
519
|
+
const defaultLoc = defaultLocale || locales[0];
|
|
520
|
+
if (!locales || locales.length <= 1) return [];
|
|
521
|
+
const currentLocale = locales.find((l) => pathSeg === l || pathSeg.startsWith(l + '/')) || defaultLoc;
|
|
522
|
+
const logicalRoute =
|
|
523
|
+
currentLocale === defaultLoc
|
|
524
|
+
? pathSeg === defaultLoc
|
|
525
|
+
? ''
|
|
526
|
+
: pathSeg.startsWith(defaultLoc + '/')
|
|
527
|
+
? pathSeg.slice(defaultLoc.length + 1)
|
|
528
|
+
: pathSeg
|
|
529
|
+
: pathSeg === currentLocale
|
|
530
|
+
? ''
|
|
531
|
+
: pathSeg.slice(currentLocale.length + 1);
|
|
532
|
+
const entries = [];
|
|
533
|
+
locales.forEach((loc) => {
|
|
534
|
+
const seg = loc === defaultLoc ? logicalRoute : (logicalRoute ? `${loc}/${logicalRoute}` : loc);
|
|
535
|
+
const href = baseClean + (seg ? `/${seg}` : '');
|
|
536
|
+
const hreflang = loc === defaultLoc ? 'x-default' : loc;
|
|
537
|
+
entries.push({ hreflang, href });
|
|
538
|
+
});
|
|
539
|
+
return entries;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function escapeXmlText(s) {
|
|
543
|
+
return String(s)
|
|
544
|
+
.replace(/&/g, '&')
|
|
545
|
+
.replace(/</g, '<')
|
|
546
|
+
.replace(/>/g, '>');
|
|
547
|
+
}
|
|
548
|
+
|
|
467
549
|
function buildOgLocale(pathSeg, locales, defaultLocale) {
|
|
468
550
|
if (locales.length <= 1) return '';
|
|
469
551
|
const defaultLoc = defaultLocale || locales[0];
|
|
@@ -530,7 +612,22 @@ function setNestedKey(obj, path, value) {
|
|
|
530
612
|
let cur = obj;
|
|
531
613
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
532
614
|
const p = parts[i];
|
|
533
|
-
|
|
615
|
+
const next = parts[i + 1];
|
|
616
|
+
const nextIsIndex = /^\d+$/.test(next);
|
|
617
|
+
if (!(p in cur) || typeof cur[p] !== 'object') {
|
|
618
|
+
cur[p] = nextIsIndex ? [] : {};
|
|
619
|
+
} else if (nextIsIndex && !Array.isArray(cur[p]) && cur[p] && typeof cur[p] === 'object') {
|
|
620
|
+
const existing = cur[p];
|
|
621
|
+
const keys = Object.keys(existing);
|
|
622
|
+
const numericOnly = keys.every((k) => /^\d+$/.test(k));
|
|
623
|
+
if (numericOnly) {
|
|
624
|
+
const arr = [];
|
|
625
|
+
keys.forEach((k) => {
|
|
626
|
+
arr[parseInt(k, 10)] = existing[k];
|
|
627
|
+
});
|
|
628
|
+
cur[p] = arr;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
534
631
|
cur = cur[p];
|
|
535
632
|
}
|
|
536
633
|
cur[parts[parts.length - 1]] = value;
|
|
@@ -568,9 +665,11 @@ function resolveHeadXBindings(html, xData) {
|
|
|
568
665
|
|
|
569
666
|
// --- SEO: robots.txt and sitemap.xml (written to output, use liveUrl for crawlers) ---
|
|
570
667
|
|
|
571
|
-
function writeSeoFiles(outputDir, pathList, liveUrl) {
|
|
668
|
+
function writeSeoFiles(outputDir, pathList, liveUrl, locales, defaultLocale) {
|
|
572
669
|
const base = liveUrl.replace(/\/$/, '');
|
|
573
670
|
const today = new Date().toISOString().slice(0, 10);
|
|
671
|
+
const localeList = Array.isArray(locales) ? locales : [];
|
|
672
|
+
const multiLocale = localeList.length > 1;
|
|
574
673
|
|
|
575
674
|
writeFileSync(
|
|
576
675
|
join(outputDir, 'robots.txt'),
|
|
@@ -582,22 +681,32 @@ Sitemap: ${base}/sitemap.xml
|
|
|
582
681
|
'utf8'
|
|
583
682
|
);
|
|
584
683
|
|
|
684
|
+
const urlsetNs = multiLocale
|
|
685
|
+
? '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'
|
|
686
|
+
: '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
|
|
687
|
+
|
|
585
688
|
const urlEntries = pathList.map((pathSeg) => {
|
|
586
689
|
const path = pathSeg === '' ? '' : '/' + pathSeg.replace(/\/$/, '');
|
|
587
690
|
const loc = path ? `${base}${path}` : base + '/';
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
691
|
+
const escapedLoc = escapeXmlText(loc);
|
|
692
|
+
let body = ` <loc>${escapedLoc}</loc>`;
|
|
693
|
+
if (multiLocale) {
|
|
694
|
+
for (const { hreflang, href } of getAlternateLinksForPath(pathSeg, localeList, defaultLocale, liveUrl)) {
|
|
695
|
+
body += `\n <xhtml:link rel="alternate" hreflang="${escapeXmlText(hreflang)}" href="${escapeXmlText(href)}" />`;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
body += `\n <lastmod>${today}</lastmod>
|
|
592
699
|
<changefreq>monthly</changefreq>
|
|
593
|
-
<priority>${path === '' ? '1.0' : '0.8'}</priority
|
|
700
|
+
<priority>${path === '' ? '1.0' : '0.8'}</priority>`;
|
|
701
|
+
return ` <url>
|
|
702
|
+
${body}
|
|
594
703
|
</url>`;
|
|
595
704
|
});
|
|
596
705
|
|
|
597
706
|
writeFileSync(
|
|
598
707
|
join(outputDir, 'sitemap.xml'),
|
|
599
708
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
600
|
-
|
|
709
|
+
${urlsetNs}
|
|
601
710
|
${urlEntries.join('\n')}
|
|
602
711
|
</urlset>
|
|
603
712
|
`,
|
|
@@ -930,7 +1039,11 @@ async function runPrerender(config) {
|
|
|
930
1039
|
xFor.includes('$url') || xFor.includes('$auth') ||
|
|
931
1040
|
/\bin\s+(filtered\w*|results|searchResults)\b/.test(xFor);
|
|
932
1041
|
const forceCollapse = explicit || inferred;
|
|
933
|
-
if (!forceCollapse)
|
|
1042
|
+
if (!forceCollapse) {
|
|
1043
|
+
tpl.removeAttribute('data-prerender-collapsed');
|
|
1044
|
+
return; // keep prerendered list for SEO
|
|
1045
|
+
}
|
|
1046
|
+
tpl.setAttribute('data-prerender-collapsed', '1');
|
|
934
1047
|
const first = tpl.content?.firstElementChild;
|
|
935
1048
|
if (!first) return;
|
|
936
1049
|
const tag = first.tagName;
|
|
@@ -968,7 +1081,9 @@ async function runPrerender(config) {
|
|
|
968
1081
|
return false;
|
|
969
1082
|
};
|
|
970
1083
|
|
|
971
|
-
|
|
1084
|
+
// Only clean up templates we intentionally collapsed above.
|
|
1085
|
+
// Running this on all x-for templates can remove valid prerendered list items.
|
|
1086
|
+
document.querySelectorAll('template[x-for][data-prerender-collapsed="1"]').forEach((tpl) => {
|
|
972
1087
|
const xFor = (tpl.getAttribute('x-for') || '').trim();
|
|
973
1088
|
const m = xFor.match(loopVarRegex);
|
|
974
1089
|
const itemVar = m ? (m[1] || m[3] || '') : '';
|
|
@@ -1063,7 +1178,14 @@ async function runPrerender(config) {
|
|
|
1063
1178
|
await browser.close();
|
|
1064
1179
|
}
|
|
1065
1180
|
|
|
1066
|
-
writeSeoFiles(
|
|
1181
|
+
writeSeoFiles(
|
|
1182
|
+
config.output,
|
|
1183
|
+
pathList.filter((p) => p !== NOT_FOUND_PATH),
|
|
1184
|
+
config.liveUrl,
|
|
1185
|
+
locales,
|
|
1186
|
+
defaultLocale
|
|
1187
|
+
);
|
|
1188
|
+
validatePrerenderedOutput(config.output, pathList.filter((p) => p !== NOT_FOUND_PATH));
|
|
1067
1189
|
|
|
1068
1190
|
if (config.redirects.length > 0) {
|
|
1069
1191
|
const lines = config.redirects.map((r) => {
|