mnfst-render 0.1.4 → 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.
@@ -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
  `,
@@ -1063,7 +1123,13 @@ async function runPrerender(config) {
1063
1123
  await browser.close();
1064
1124
  }
1065
1125
 
1066
- 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
+ );
1067
1133
 
1068
1134
  if (config.redirects.length > 0) {
1069
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.4",
3
+ "version": "0.1.5",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {