mnfst-render 0.1.3 → 0.1.5

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 +90 -22
  2. package/package.json +1 -1
@@ -464,6 +464,39 @@ function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
464
464
  return out;
465
465
  }
466
466
 
467
+ /** Same alternate URLs as buildCanonicalAndHreflang; used for sitemap xhtml:link entries. */
468
+ function getAlternateLinksForPath(pathSeg, locales, defaultLocale, base) {
469
+ const baseClean = base.replace(/\/$/, '');
470
+ const defaultLoc = defaultLocale || locales[0];
471
+ if (!locales || locales.length <= 1) return [];
472
+ const currentLocale = locales.find((l) => pathSeg === l || pathSeg.startsWith(l + '/')) || defaultLoc;
473
+ const logicalRoute =
474
+ currentLocale === defaultLoc
475
+ ? pathSeg === defaultLoc
476
+ ? ''
477
+ : pathSeg.startsWith(defaultLoc + '/')
478
+ ? pathSeg.slice(defaultLoc.length + 1)
479
+ : pathSeg
480
+ : pathSeg === currentLocale
481
+ ? ''
482
+ : pathSeg.slice(currentLocale.length + 1);
483
+ const entries = [];
484
+ locales.forEach((loc) => {
485
+ const seg = loc === defaultLoc ? logicalRoute : (logicalRoute ? `${loc}/${logicalRoute}` : loc);
486
+ const href = baseClean + (seg ? `/${seg}` : '');
487
+ const hreflang = loc === defaultLoc ? 'x-default' : loc;
488
+ entries.push({ hreflang, href });
489
+ });
490
+ return entries;
491
+ }
492
+
493
+ function escapeXmlText(s) {
494
+ return String(s)
495
+ .replace(/&/g, '&amp;')
496
+ .replace(/</g, '&lt;')
497
+ .replace(/>/g, '&gt;');
498
+ }
499
+
467
500
  function buildOgLocale(pathSeg, locales, defaultLocale) {
468
501
  if (locales.length <= 1) return '';
469
502
  const defaultLoc = defaultLocale || locales[0];
@@ -530,7 +563,22 @@ function setNestedKey(obj, path, value) {
530
563
  let cur = obj;
531
564
  for (let i = 0; i < parts.length - 1; i++) {
532
565
  const p = parts[i];
533
- if (!(p in cur) || typeof cur[p] !== 'object') cur[p] = {};
566
+ const next = parts[i + 1];
567
+ const nextIsIndex = /^\d+$/.test(next);
568
+ if (!(p in cur) || typeof cur[p] !== 'object') {
569
+ cur[p] = nextIsIndex ? [] : {};
570
+ } else if (nextIsIndex && !Array.isArray(cur[p]) && cur[p] && typeof cur[p] === 'object') {
571
+ const existing = cur[p];
572
+ const keys = Object.keys(existing);
573
+ const numericOnly = keys.every((k) => /^\d+$/.test(k));
574
+ if (numericOnly) {
575
+ const arr = [];
576
+ keys.forEach((k) => {
577
+ arr[parseInt(k, 10)] = existing[k];
578
+ });
579
+ cur[p] = arr;
580
+ }
581
+ }
534
582
  cur = cur[p];
535
583
  }
536
584
  cur[parts[parts.length - 1]] = value;
@@ -568,9 +616,11 @@ function resolveHeadXBindings(html, xData) {
568
616
 
569
617
  // --- SEO: robots.txt and sitemap.xml (written to output, use liveUrl for crawlers) ---
570
618
 
571
- function writeSeoFiles(outputDir, pathList, liveUrl) {
619
+ function writeSeoFiles(outputDir, pathList, liveUrl, locales, defaultLocale) {
572
620
  const base = liveUrl.replace(/\/$/, '');
573
621
  const today = new Date().toISOString().slice(0, 10);
622
+ const localeList = Array.isArray(locales) ? locales : [];
623
+ const multiLocale = localeList.length > 1;
574
624
 
575
625
  writeFileSync(
576
626
  join(outputDir, 'robots.txt'),
@@ -582,22 +632,32 @@ Sitemap: ${base}/sitemap.xml
582
632
  'utf8'
583
633
  );
584
634
 
635
+ const urlsetNs = multiLocale
636
+ ? '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">'
637
+ : '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
638
+
585
639
  const urlEntries = pathList.map((pathSeg) => {
586
640
  const path = pathSeg === '' ? '' : '/' + pathSeg.replace(/\/$/, '');
587
641
  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>
642
+ const escapedLoc = escapeXmlText(loc);
643
+ let body = ` <loc>${escapedLoc}</loc>`;
644
+ if (multiLocale) {
645
+ for (const { hreflang, href } of getAlternateLinksForPath(pathSeg, localeList, defaultLocale, liveUrl)) {
646
+ body += `\n <xhtml:link rel="alternate" hreflang="${escapeXmlText(hreflang)}" href="${escapeXmlText(href)}" />`;
647
+ }
648
+ }
649
+ body += `\n <lastmod>${today}</lastmod>
592
650
  <changefreq>monthly</changefreq>
593
- <priority>${path === '' ? '1.0' : '0.8'}</priority>
651
+ <priority>${path === '' ? '1.0' : '0.8'}</priority>`;
652
+ return ` <url>
653
+ ${body}
594
654
  </url>`;
595
655
  });
596
656
 
597
657
  writeFileSync(
598
658
  join(outputDir, 'sitemap.xml'),
599
659
  `<?xml version="1.0" encoding="UTF-8"?>
600
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
660
+ ${urlsetNs}
601
661
  ${urlEntries.join('\n')}
602
662
  </urlset>
603
663
  `,
@@ -953,6 +1013,20 @@ async function runPrerender(config) {
953
1013
  await page.evaluate(() => {
954
1014
  const loopVarRegex = /^\s*(?:\(\s*([A-Za-z_$][\w$]*)(?:\s*,\s*([A-Za-z_$][\w$]*))?\s*\)|([A-Za-z_$][\w$]*))\s+in\s+/;
955
1015
  const bindingAttrRegex = /^(?:x-bind:|:|x-text|x-html|x-show|x-if|x-model|x-effect|x-on:|@)/;
1016
+ const hasVar = (expr, varName) => varName && new RegExp(`\\b${varName}\\b`).test(expr || '');
1017
+ const elementReferencesLoopScope = (el, itemVar, indexVar) => {
1018
+ if (!el) return false;
1019
+ const nodes = [el, ...Array.from(el.querySelectorAll('*'))];
1020
+ for (const node of nodes) {
1021
+ const attrs = node.attributes ? Array.from(node.attributes) : [];
1022
+ for (const attr of attrs) {
1023
+ if (!bindingAttrRegex.test(attr.name)) continue;
1024
+ const expr = attr.value || '';
1025
+ if (hasVar(expr, itemVar) || hasVar(expr, indexVar)) return true;
1026
+ }
1027
+ }
1028
+ return false;
1029
+ };
956
1030
 
957
1031
  document.querySelectorAll('template[x-for]').forEach((tpl) => {
958
1032
  const xFor = (tpl.getAttribute('x-for') || '').trim();
@@ -970,19 +1044,7 @@ async function runPrerender(config) {
970
1044
  const sameTag = next.tagName === tag;
971
1045
  if (!sameTag) break;
972
1046
 
973
- let referencesLoopScope = false;
974
- const attrNodes = next.attributes ? Array.from(next.attributes) : [];
975
- for (const attr of attrNodes) {
976
- if (!bindingAttrRegex.test(attr.name)) continue;
977
- const expr = attr.value || '';
978
- if (
979
- (itemVar && new RegExp(`\\b${itemVar}\\b`).test(expr)) ||
980
- (indexVar && new RegExp(`\\b${indexVar}\\b`).test(expr))
981
- ) {
982
- referencesLoopScope = true;
983
- break;
984
- }
985
- }
1047
+ const referencesLoopScope = elementReferencesLoopScope(next, itemVar, indexVar);
986
1048
 
987
1049
  const toRemove = next;
988
1050
  next = next.nextElementSibling;
@@ -1061,7 +1123,13 @@ async function runPrerender(config) {
1061
1123
  await browser.close();
1062
1124
  }
1063
1125
 
1064
- writeSeoFiles(config.output, pathList.filter((p) => p !== NOT_FOUND_PATH), config.liveUrl);
1126
+ writeSeoFiles(
1127
+ config.output,
1128
+ pathList.filter((p) => p !== NOT_FOUND_PATH),
1129
+ config.liveUrl,
1130
+ locales,
1131
+ defaultLocale
1132
+ );
1065
1133
 
1066
1134
  if (config.redirects.length > 0) {
1067
1135
  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.3",
3
+ "version": "0.1.5",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {