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.
Files changed (2) hide show
  1. package/manifest.render.mjs +143 -21
  2. package/package.json +1 -1
@@ -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
- if (c === '/' || c === '') continue;
152
- if (c.startsWith('/')) {
153
- paths.add(c === '/' ? '/' : c.replace(/^\//, '').replace(/\/$/, '') || '/');
154
- } else {
155
- paths.add('/' + c.replace(/\/$/, ''));
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).forEach((c) => {
313
- if (c.startsWith('/')) pathSet.add(c === '/' ? '/' : c.replace(/^\//, '').replace(/\/$/, '') || '/');
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, '&amp;')
545
+ .replace(/</g, '&lt;')
546
+ .replace(/>/g, '&gt;');
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
- if (!(p in cur) || typeof cur[p] !== 'object') cur[p] = {};
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 escaped = loc.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
589
- return ` <url>
590
- <loc>${escaped}</loc>
591
- <lastmod>${today}</lastmod>
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
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
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) return; // keep prerendered list for SEO
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
- document.querySelectorAll('template[x-for]').forEach((tpl) => {
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(config.output, pathList.filter((p) => p !== NOT_FOUND_PATH), config.liveUrl);
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {