@goflag/cli 0.2.5 → 0.2.8

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.
@@ -970,7 +970,8 @@ async function inspect(url, options = {}) {
970
970
  extractor: {
971
971
  mode: "static",
972
972
  escalated: false,
973
- escalationReason: `would-have-escalated (${heuristic.reason}) but ${err.message}`
973
+ escalationReason: heuristic.reason,
974
+ escalationBlocked: err.message
974
975
  },
975
976
  html: { static: staticBody },
976
977
  probes: probes2
@@ -1115,6 +1116,7 @@ function push(out, seen, href, source) {
1115
1116
  }
1116
1117
 
1117
1118
  // src/lib/core/crawl.ts
1119
+ var MAX_ATTEMPTS = 2;
1118
1120
  async function crawl(options) {
1119
1121
  const depthLimit = options.depth ?? 1;
1120
1122
  const concurrency = Math.max(1, options.concurrency ?? 4);
@@ -1128,6 +1130,7 @@ async function crawl(options) {
1128
1130
  pages: [],
1129
1131
  visited: [],
1130
1132
  errors: [{ url: options.entryUrl, message: "entry URL is not a valid http(s) URL" }],
1133
+ recovered: [],
1131
1134
  truncated: false
1132
1135
  };
1133
1136
  }
@@ -1143,6 +1146,7 @@ async function crawl(options) {
1143
1146
  }
1144
1147
  const pages = [];
1145
1148
  const errors = [];
1149
+ const recovered = [];
1146
1150
  let truncated = false;
1147
1151
  const seenFinal = /* @__PURE__ */ new Set();
1148
1152
  while (queue.length > 0) {
@@ -1163,9 +1167,15 @@ async function crawl(options) {
1163
1167
  const results = await Promise.all(inspections);
1164
1168
  for (const result of results) {
1165
1169
  if (result.kind === "err") {
1170
+ const attempt = (result.item.attempt ?? 0) + 1;
1171
+ if (attempt < MAX_ATTEMPTS) {
1172
+ queue.push({ ...result.item, attempt });
1173
+ continue;
1174
+ }
1166
1175
  errors.push({ url: result.item.url, message: result.error });
1167
1176
  continue;
1168
1177
  }
1178
+ if (result.item.attempt) recovered.push(result.item.url);
1169
1179
  const finalUrl = canonicaliseUrl(result.page.fetch.finalUrl) ?? result.page.fetch.finalUrl;
1170
1180
  if (seenFinal.has(finalUrl)) continue;
1171
1181
  seenFinal.add(finalUrl);
@@ -1198,6 +1208,7 @@ async function crawl(options) {
1198
1208
  pages,
1199
1209
  visited: [...visitedSet],
1200
1210
  errors,
1211
+ recovered,
1201
1212
  truncated
1202
1213
  };
1203
1214
  }
@@ -1784,17 +1795,60 @@ function sortIssues(issues) {
1784
1795
  });
1785
1796
  }
1786
1797
 
1787
- // src/lib/core/i18n.ts
1788
- var BCP47_LOOSE = /^[a-z]{2,3}(-[a-z]{2}|-\d{3})?$/i;
1789
- function localeIdentity(tag) {
1790
- return tag.trim().toLowerCase();
1791
- }
1798
+ // src/lib/core/bcp47.ts
1799
+ var languageNames = new Intl.DisplayNames(["en"], { type: "language", fallback: "none" });
1800
+ var regionNames = new Intl.DisplayNames(["en"], { type: "region", fallback: "none" });
1801
+ var scriptNames = new Intl.DisplayNames(["en"], { type: "script", fallback: "none" });
1802
+ var NOT_AN_AUDIENCE_REGION = /* @__PURE__ */ new Set(["ZZ", "XA", "XB"]);
1803
+ var NOT_AN_AUDIENCE_LANGUAGE = /* @__PURE__ */ new Set(["mul", "zxx", "und", "mis"]);
1804
+ var NOT_A_SCRIPT = /* @__PURE__ */ new Set(["Zzzz"]);
1805
+ var ICU_KNOWS_LANGUAGES = languageNames.of("fr") !== void 0 && languageNames.of("qq") === void 0 && regionNames.of("BR") !== void 0;
1806
+ var ICU_UNAVAILABLE_WARNING = "This Node build cannot tell a real language tag from an invented one (small-icu). hreflang tags were checked for shape only, so `locale.invalid` may have missed tags that do not exist.";
1807
+ var BCP47_SHAPE = /^[a-z]{2,3}(-[a-z]{2}|-\d{3})?$/i;
1792
1808
  function looksLikeLocaleSegment(segment) {
1793
- return BCP47_LOOSE.test(segment);
1809
+ return BCP47_SHAPE.test(segment);
1794
1810
  }
1795
1811
  function isValidLocale(tag) {
1796
1812
  if (tag === "x-default") return true;
1797
- return BCP47_LOOSE.test(tag);
1813
+ if (!ICU_KNOWS_LANGUAGES) return BCP47_SHAPE.test(tag);
1814
+ let canonical;
1815
+ try {
1816
+ canonical = Intl.getCanonicalLocales(tag)[0] ?? "";
1817
+ } catch {
1818
+ return false;
1819
+ }
1820
+ if (!canonical) return false;
1821
+ let parts;
1822
+ try {
1823
+ parts = new Intl.Locale(canonical);
1824
+ } catch {
1825
+ return false;
1826
+ }
1827
+ const language = (parts.language ?? "").toLowerCase();
1828
+ if (!language) return false;
1829
+ if (NOT_AN_AUDIENCE_LANGUAGE.has(language)) return false;
1830
+ if (languageNames.of(language) === void 0) return false;
1831
+ if (parts.script) {
1832
+ if (NOT_A_SCRIPT.has(parts.script)) return false;
1833
+ if (scriptNames.of(parts.script) === void 0) return false;
1834
+ }
1835
+ if (parts.region) {
1836
+ if (NOT_AN_AUDIENCE_REGION.has(parts.region)) return false;
1837
+ if (regionNames.of(parts.region) === void 0) return false;
1838
+ }
1839
+ return true;
1840
+ }
1841
+ function isKnownLanguageTag(tag) {
1842
+ const primary = (tag.toLowerCase().split("-")[0] ?? "").trim();
1843
+ if (!primary) return false;
1844
+ if (NOT_AN_AUDIENCE_LANGUAGE.has(primary)) return false;
1845
+ if (!ICU_KNOWS_LANGUAGES) return BCP47_SHAPE.test(primary);
1846
+ return languageNames.of(primary) !== void 0;
1847
+ }
1848
+
1849
+ // src/lib/core/i18n.ts
1850
+ function localeIdentity(tag) {
1851
+ return tag.trim().toLowerCase();
1798
1852
  }
1799
1853
  function buildI18nMatrix(pages, options = {}) {
1800
1854
  const inspectedByUrl = /* @__PURE__ */ new Map();
@@ -1802,10 +1856,18 @@ function buildI18nMatrix(pages, options = {}) {
1802
1856
  const grid = /* @__PURE__ */ new Map();
1803
1857
  const locales = /* @__PURE__ */ new Set();
1804
1858
  const routes = /* @__PURE__ */ new Set();
1859
+ const declaredSet = new Set(options.declaredUrls ?? []);
1805
1860
  for (const locale of options.locales ?? []) {
1806
1861
  const tag = locale.trim().toLowerCase();
1807
1862
  if (tag) locales.add(tag);
1808
1863
  }
1864
+ function rowOf2(url, pathnameRoute) {
1865
+ return options.clusterRouteOf?.(url) ?? pathnameRoute;
1866
+ }
1867
+ function sourceOf(url) {
1868
+ if (inspectedByUrl.has(url)) return "crawled";
1869
+ return declaredSet.has(url) ? "sitemap" : "alternate";
1870
+ }
1809
1871
  function record(route, locale, url) {
1810
1872
  routes.add(route);
1811
1873
  locales.add(locale);
@@ -1815,13 +1877,13 @@ function buildI18nMatrix(pages, options = {}) {
1815
1877
  grid.set(route, row);
1816
1878
  }
1817
1879
  if (!row.has(locale)) {
1818
- row.set(locale, { url, inspected: inspectedByUrl.has(url) });
1880
+ row.set(locale, { url, inspected: inspectedByUrl.has(url), source: sourceOf(url) });
1819
1881
  }
1820
1882
  }
1821
1883
  for (const page of pages) {
1822
1884
  const url = new URL(page.fetch.finalUrl);
1823
1885
  const { route: selfRoute, locale: selfLocale } = splitRoute(url.pathname);
1824
- record(selfRoute, selfLocale, page.fetch.finalUrl);
1886
+ record(rowOf2(page.fetch.finalUrl, selfRoute), selfLocale, page.fetch.finalUrl);
1825
1887
  for (const alt of page.links.alternates) {
1826
1888
  let altUrl;
1827
1889
  try {
@@ -1831,7 +1893,7 @@ function buildI18nMatrix(pages, options = {}) {
1831
1893
  }
1832
1894
  const { route } = splitRoute(altUrl.pathname);
1833
1895
  const locale = alt.isXDefault ? "x-default" : localeIdentity(alt.hreflang);
1834
- record(route || selfRoute, locale, altUrl.toString());
1896
+ record(rowOf2(altUrl.toString(), route || selfRoute), locale, altUrl.toString());
1835
1897
  }
1836
1898
  }
1837
1899
  for (const declared of options.declaredUrls ?? []) {
@@ -1842,7 +1904,7 @@ function buildI18nMatrix(pages, options = {}) {
1842
1904
  continue;
1843
1905
  }
1844
1906
  const { route, locale } = splitRoute(url.pathname);
1845
- record(route, locale, url.toString());
1907
+ record(rowOf2(url.toString(), route), locale, url.toString());
1846
1908
  }
1847
1909
  const sortedLocales = sortLocales([...locales]);
1848
1910
  const sortedRoutes = [...routes].sort((a, b) => a.localeCompare(b));
@@ -1851,11 +1913,20 @@ function buildI18nMatrix(pages, options = {}) {
1851
1913
  cells[route] = {};
1852
1914
  for (const locale of sortedLocales) {
1853
1915
  const slot = grid.get(route)?.get(locale);
1854
- cells[route][locale] = slot ? { url: slot.url, inspected: slot.inspected } : { url: null, inspected: false };
1916
+ cells[route][locale] = slot ? { url: slot.url, inspected: slot.inspected, source: slot.source } : { url: null, inspected: false };
1855
1917
  }
1856
1918
  }
1857
1919
  return { locales: sortedLocales, routes: sortedRoutes, cells };
1858
1920
  }
1921
+ function countUnverifiedAlternates(matrix) {
1922
+ let count2 = 0;
1923
+ for (const route of matrix.routes) {
1924
+ for (const locale of matrix.locales) {
1925
+ if (matrix.cells[route]?.[locale]?.source === "alternate") count2++;
1926
+ }
1927
+ }
1928
+ return count2;
1929
+ }
1859
1930
  function reciprocityIssues(pages) {
1860
1931
  const issues = [];
1861
1932
  const byUrl = /* @__PURE__ */ new Map();
@@ -1956,6 +2027,9 @@ function declaredLocales(page) {
1956
2027
  }
1957
2028
  return out;
1958
2029
  }
2030
+ function rowOf(site, url, pathname) {
2031
+ return site.clusterRouteOf?.(url) ?? splitRoute(pathname).route;
2032
+ }
1959
2033
  function sitemapLocalesByRoute(site) {
1960
2034
  const byRoute = /* @__PURE__ */ new Map();
1961
2035
  for (const entry of site.discovery?.urls ?? []) {
@@ -1965,8 +2039,9 @@ function sitemapLocalesByRoute(site) {
1965
2039
  } catch {
1966
2040
  continue;
1967
2041
  }
1968
- const { route, locale } = splitRoute(pathname);
2042
+ const { locale } = splitRoute(pathname);
1969
2043
  if (locale === "x-default") continue;
2044
+ const route = rowOf(site, entry.loc, pathname);
1970
2045
  const set = byRoute.get(route) ?? /* @__PURE__ */ new Set();
1971
2046
  set.add(locale.toLowerCase());
1972
2047
  byRoute.set(route, set);
@@ -2030,7 +2105,7 @@ var hreflangSitemapMismatch = {
2030
2105
  } catch {
2031
2106
  continue;
2032
2107
  }
2033
- const { route } = splitRoute(pathname);
2108
+ const route = rowOf(site, page.fetch.finalUrl, pathname);
2034
2109
  const inSitemap = bySitemap.get(route);
2035
2110
  if (!inSitemap || inSitemap.size === 0) continue;
2036
2111
  const onlyInSitemap = sorted(new Set([...inSitemap].filter((l) => !head.has(l))));
@@ -2159,13 +2234,6 @@ function sortSiteIssues(drafts) {
2159
2234
  }
2160
2235
 
2161
2236
  // src/lib/core/locales.ts
2162
- var ISO_639_1 = new Set(
2163
- "aa ab ae af ak am an ar as av ay az ba be bg bh bi bm bn bo br bs ca ce ch co cr cs cu cv cy da de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd gl gn gu gv ha he hi ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg li ln lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo wa wo xh yi yo za zh zu".split(" ")
2164
- );
2165
- function isKnownLanguageTag(tag) {
2166
- const primary = tag.toLowerCase().split("-")[0] ?? "";
2167
- return ISO_639_1.has(primary);
2168
- }
2169
2237
  function localePrefixOf(url) {
2170
2238
  let pathname;
2171
2239
  try {
@@ -2246,6 +2314,26 @@ import { gunzipSync } from "zlib";
2246
2314
 
2247
2315
  // src/lib/core/sitemap/parse.ts
2248
2316
  import * as cheerio2 from "cheerio";
2317
+ function alternatesOf(node) {
2318
+ const out = [];
2319
+ const seen = /* @__PURE__ */ new Set();
2320
+ node.children().each((_, el) => {
2321
+ const tag = "tagName" in el ? el.tagName ?? "" : "";
2322
+ if (tag.split(":").pop()?.toLowerCase() !== "link") return;
2323
+ const attrs = "attribs" in el ? el.attribs : void 0;
2324
+ if (!attrs) return;
2325
+ const rel = (attrs.rel ?? "alternate").trim().toLowerCase();
2326
+ if (rel !== "alternate") return;
2327
+ const hreflang = (attrs.hreflang ?? "").trim();
2328
+ const href = (attrs.href ?? "").trim();
2329
+ if (!hreflang || !href) return;
2330
+ const key = `${hreflang.toLowerCase()}\0${href}`;
2331
+ if (seen.has(key)) return;
2332
+ seen.add(key);
2333
+ out.push({ hreflang, href });
2334
+ });
2335
+ return out;
2336
+ }
2249
2337
  function parseSitemap(body) {
2250
2338
  const trimmed = body.trim();
2251
2339
  if (trimmed.length === 0) {
@@ -2284,6 +2372,8 @@ function parseSitemap(body) {
2284
2372
  if (changefreq) entry.changefreq = changefreq;
2285
2373
  const priority = node.find("priority").first().text().trim();
2286
2374
  if (priority) entry.priority = priority;
2375
+ const alternates = alternatesOf(node);
2376
+ if (alternates.length > 0) entry.alternates = alternates;
2287
2377
  urls.push(entry);
2288
2378
  });
2289
2379
  return { kind: "urlset", wellFormed: true, urls };
@@ -2503,6 +2593,155 @@ function relaxTlsIfRequested2(enabled) {
2503
2593
  };
2504
2594
  }
2505
2595
 
2596
+ // src/lib/core/clusters.ts
2597
+ function normalise(raw) {
2598
+ let url;
2599
+ try {
2600
+ url = new URL(raw);
2601
+ } catch {
2602
+ return null;
2603
+ }
2604
+ url.hash = "";
2605
+ const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : url.pathname;
2606
+ return `${url.origin}${path}${url.search}`;
2607
+ }
2608
+ var X_DEFAULT = "x-default";
2609
+ function clusterCount(rows) {
2610
+ return new Set(rows.values()).size;
2611
+ }
2612
+ function indexOf(rows, conflicts, refused) {
2613
+ return {
2614
+ rows,
2615
+ routeOf: (url) => {
2616
+ const key = normalise(url);
2617
+ return key === null ? void 0 : rows.get(key);
2618
+ },
2619
+ size: clusterCount(rows),
2620
+ conflicts,
2621
+ refused
2622
+ };
2623
+ }
2624
+ function buildClusterIndex(entries) {
2625
+ const rows = /* @__PURE__ */ new Map();
2626
+ const conflicts = [];
2627
+ let refused = 0;
2628
+ for (const entry of entries) {
2629
+ const alternates = entry.alternates;
2630
+ if (!alternates || alternates.length === 0) continue;
2631
+ const xDefault = alternates.find((a) => a.hreflang.trim().toLowerCase() === X_DEFAULT);
2632
+ if (!xDefault) {
2633
+ refused++;
2634
+ continue;
2635
+ }
2636
+ const anchorUrl = normalise(xDefault.href);
2637
+ if (!anchorUrl) {
2638
+ refused++;
2639
+ continue;
2640
+ }
2641
+ const { route: anchorRoute } = splitRoute(new URL(anchorUrl).pathname);
2642
+ const members = [entry.loc, ...alternates.map((a) => a.href)];
2643
+ for (const member of members) {
2644
+ const url = normalise(member);
2645
+ if (!url) continue;
2646
+ const existing = rows.get(url);
2647
+ if (existing === void 0) {
2648
+ rows.set(url, anchorRoute);
2649
+ continue;
2650
+ }
2651
+ if (existing !== anchorRoute && !conflicts.includes(url)) conflicts.push(url);
2652
+ }
2653
+ }
2654
+ return indexOf(rows, conflicts, refused);
2655
+ }
2656
+ function readHeads(pages) {
2657
+ const seen = /* @__PURE__ */ new Map();
2658
+ for (const page of pages) {
2659
+ const self = normalise(page.fetch.finalUrl);
2660
+ if (self === null || seen.has(self)) continue;
2661
+ const peers = /* @__PURE__ */ new Set();
2662
+ const xDefaults = /* @__PURE__ */ new Set();
2663
+ for (const alt of page.links.alternates) {
2664
+ const href = normalise(alt.href);
2665
+ if (href === null) continue;
2666
+ if (alt.isXDefault) xDefaults.add(href);
2667
+ else if (href !== self) peers.add(href);
2668
+ }
2669
+ seen.set(self, { peers, xDefault: xDefaults.size === 1 ? [...xDefaults][0] : null });
2670
+ }
2671
+ return seen;
2672
+ }
2673
+ function buildHeadClusterIndex(pages) {
2674
+ const seen = readHeads(pages);
2675
+ const parent = /* @__PURE__ */ new Map();
2676
+ for (const url of seen.keys()) parent.set(url, url);
2677
+ const find = (start) => {
2678
+ let root = start;
2679
+ while (parent.get(root) !== root) root = parent.get(root);
2680
+ let cursor = start;
2681
+ while (parent.get(cursor) !== root) {
2682
+ const next = parent.get(cursor);
2683
+ parent.set(cursor, root);
2684
+ cursor = next;
2685
+ }
2686
+ return root;
2687
+ };
2688
+ for (const [url, decl] of seen) {
2689
+ for (const peer of decl.peers) {
2690
+ if (!seen.get(peer)?.peers.has(url)) continue;
2691
+ const [a, b] = [find(url), find(peer)];
2692
+ if (a !== b) parent.set(a, b);
2693
+ }
2694
+ }
2695
+ const components = /* @__PURE__ */ new Map();
2696
+ for (const url of seen.keys()) {
2697
+ const root = find(url);
2698
+ const members = components.get(root);
2699
+ if (members) members.push(url);
2700
+ else components.set(root, [url]);
2701
+ }
2702
+ const rows = /* @__PURE__ */ new Map();
2703
+ let refused = 0;
2704
+ for (const members of components.values()) {
2705
+ if (members.length < 2) continue;
2706
+ const declared = new Set(
2707
+ members.map((m) => seen.get(m)?.xDefault).filter((x) => Boolean(x))
2708
+ );
2709
+ if (declared.size !== 1) {
2710
+ refused++;
2711
+ continue;
2712
+ }
2713
+ const anchor = [...declared][0];
2714
+ if (!members.includes(anchor)) {
2715
+ refused++;
2716
+ continue;
2717
+ }
2718
+ const { route } = splitRoute(new URL(anchor).pathname);
2719
+ for (const member of members) rows.set(member, route);
2720
+ }
2721
+ return indexOf(rows, [], refused);
2722
+ }
2723
+ function combineClusterIndexes(primary, secondary) {
2724
+ const rows = new Map(secondary.rows);
2725
+ const conflicts = [...primary.conflicts];
2726
+ const add = (url) => {
2727
+ if (!conflicts.includes(url)) conflicts.push(url);
2728
+ };
2729
+ for (const url of secondary.conflicts) add(url);
2730
+ for (const [url, route] of primary.rows) {
2731
+ const claimed = rows.get(url);
2732
+ if (claimed !== void 0 && claimed !== route) add(url);
2733
+ rows.set(url, route);
2734
+ }
2735
+ return indexOf(rows, conflicts, primary.refused + secondary.refused);
2736
+ }
2737
+ function clustersOnlyFrom(primary, secondary) {
2738
+ const routes = /* @__PURE__ */ new Set();
2739
+ for (const [url, route] of secondary.rows) {
2740
+ if (!primary.rows.has(url)) routes.add(route);
2741
+ }
2742
+ return routes.size;
2743
+ }
2744
+
2506
2745
  // src/lib/core/coverage.ts
2507
2746
  var DEFAULT_PER_FAMILY = 3;
2508
2747
  var DEFAULT_THRESHOLD = 8;
@@ -3435,6 +3674,11 @@ function localeOfUrl(url) {
3435
3674
  }
3436
3675
  return null;
3437
3676
  }
3677
+ function blockedEscalationWarning(pages) {
3678
+ const blocked = pages.filter((p) => p.extractor.escalationBlocked);
3679
+ if (blocked.length === 0) return null;
3680
+ return `${blocked.length} page(s) looked client-rendered and were judged on their static HTML, because the browser could not be started: ${blocked[0].extractor.escalationBlocked} Their metadata findings may be phantoms \u2014 install Playwright and Chromium, or pass --static to say the static HTML is what you meant to judge.`;
3681
+ }
3438
3682
  function deriveTranslationHoles(matrix, ignoreRoutes = []) {
3439
3683
  const realLocales = matrix.locales.filter((l) => l !== "x-default");
3440
3684
  if (realLocales.length < 2) return [];
@@ -3501,6 +3745,7 @@ async function runAudit(inputUrl, options = {}) {
3501
3745
  crawlFallback: false
3502
3746
  }).catch(() => void 0);
3503
3747
  const allSitemapUrls = (discovery?.urls ?? []).map((u) => u.loc);
3748
+ const sitemapClusters = buildClusterIndex(discovery?.urls ?? []);
3504
3749
  const coverageMode = options.coverage ?? (allSitemapUrls.length > 0 ? "structural" : "all");
3505
3750
  const selection = coverageMode === "structural" && allSitemapUrls.length > 0 ? selectByStructure(allSitemapUrls, { locales: options.locales }) : void 0;
3506
3751
  const sitemapUrls = selection?.urls ?? allSitemapUrls;
@@ -3533,6 +3778,13 @@ async function runAudit(inputUrl, options = {}) {
3533
3778
  });
3534
3779
  const pages = crawlResult.pages;
3535
3780
  const warnings = crawlResult.errors.map((e) => `crawl: ${e.url} \u2014 ${e.message}`);
3781
+ if (crawlResult.recovered.length > 0) {
3782
+ warnings.push(
3783
+ `${crawlResult.recovered.length} page(s) failed once and answered on retry: ${crawlResult.recovered.slice(0, 3).join(", ")}${crawlResult.recovered.length > 3 ? `, +${crawlResult.recovered.length - 3} more` : ""}.`
3784
+ );
3785
+ }
3786
+ const escalationWarning = blockedEscalationWarning(pages);
3787
+ if (escalationWarning) warnings.push(escalationWarning);
3536
3788
  const okPages = pages.filter((p) => isOkStatus(p.fetch.status));
3537
3789
  const documents = okPages.filter(isHtmlPage);
3538
3790
  const { kept: htmlPages, dropped: duplicatePages } = dropCanonicalDuplicates(documents);
@@ -3595,10 +3847,26 @@ async function runAudit(inputUrl, options = {}) {
3595
3847
  suggestion ? `No sitemap and no --locales: i18n checks are off. Locale-looking prefixes found \u2014 re-run with \`--locales ${suggestion}\` to enable them.` : `No sitemap and no --locales: i18n checks are off. Prefixes seen (${localeAxis.candidates.map((c) => c.tag).join(", ")}) do not look like locales; pass --locales if any of them is one.`
3596
3848
  );
3597
3849
  }
3850
+ if (!ICU_KNOWS_LANGUAGES) warnings.push(ICU_UNAVAILABLE_WARNING);
3851
+ const headClusters = buildHeadClusterIndex(htmlPages);
3852
+ const clusters = combineClusterIndexes(sitemapClusters, headClusters);
3853
+ const clustersFromHead = clustersOnlyFrom(sitemapClusters, headClusters);
3598
3854
  const matrix = buildI18nMatrix(htmlPages, {
3599
3855
  declaredUrls: sitemapUrls,
3600
- locales: localeAxis.locales
3856
+ locales: localeAxis.locales,
3857
+ clusterRouteOf: clusters.routeOf
3601
3858
  });
3859
+ if (clusters.conflicts.length > 0) {
3860
+ warnings.push(
3861
+ `${clusters.conflicts.length} URL(s) are declared in two different hreflang clusters by the sitemap and/or the pages' \`<head>\`; the sitemap's declaration was kept: ${clusters.conflicts.slice(0, 3).join(", ")}${clusters.conflicts.length > 3 ? `, +${clusters.conflicts.length - 3} more` : ""}.`
3862
+ );
3863
+ }
3864
+ const unverifiedAlternates = countUnverifiedAlternates(matrix);
3865
+ if (unverifiedAlternates > 0) {
3866
+ warnings.push(
3867
+ `${unverifiedAlternates} translation${unverifiedAlternates === 1 ? "" : "s"} in the matrix ${unverifiedAlternates === 1 ? "is" : "are"} vouched for only by an \`hreflang\` on another page \u2014 not crawled, and not in the sitemap. They are counted as present, so a page advertised but not served reads as translated rather than missing.`
3868
+ );
3869
+ }
3602
3870
  const holes = localeAxis.multilingual ? deriveTranslationHoles(matrix, options.ignoreHoles) : [];
3603
3871
  const ignoredHoles = localeAxis.multilingual && (options.ignoreHoles?.length ?? 0) > 0 ? deriveTranslationHoles(matrix).length - holes.length : 0;
3604
3872
  const reciprocity = reciprocityIssues(htmlPages).map((issue) => ({
@@ -3617,7 +3885,11 @@ async function runAudit(inputUrl, options = {}) {
3617
3885
  matrix,
3618
3886
  localeAxis,
3619
3887
  discovery,
3620
- robots
3888
+ robots,
3889
+ // Same index the matrix was built from, so a rule that groups by route
3890
+ // and the grid that reports holes cannot disagree about which URLs are
3891
+ // one page.
3892
+ clusterRouteOf: clusters.routeOf
3621
3893
  };
3622
3894
  const siteOccurrence = /* @__PURE__ */ new Map();
3623
3895
  const siteIssues = lintSite(siteContext).map((draft) => {
@@ -3743,6 +4015,24 @@ async function runAudit(inputUrl, options = {}) {
3743
4015
  },
3744
4016
  ...ignoredHoles > 0 ? { ignoredHoles } : {},
3745
4017
  ...duplicatePages > 0 ? { duplicatePages } : {},
4018
+ ...unverifiedAlternates > 0 ? { unverifiedAlternates } : {},
4019
+ // Only when the site declared clusters. A merge that changes which rows
4020
+ // exist must be visible: the alternative is a hole count that moved for
4021
+ // a reason nothing in the report explains.
4022
+ ...clusters.size > 0 || clusters.refused > 0 ? {
4023
+ declaredClusters: {
4024
+ count: clusters.size,
4025
+ ...clusters.conflicts.length > 0 ? { conflicts: clusters.conflicts.length } : {},
4026
+ // Which source did the work. On a site that declares both ways
4027
+ // the two indexes describe the same clusters, so this counts the
4028
+ // rows only the `<head>` supplied rather than a difference of
4029
+ // totals that would claim a gain that is not there.
4030
+ ...clustersFromHead > 0 ? { fromHead: clustersFromHead } : {},
4031
+ // Declared and not used, for want of an anchor to name the row
4032
+ // after. A declaration we saw and did not act on has to be said.
4033
+ ...clusters.refused > 0 ? { refused: clusters.refused } : {}
4034
+ }
4035
+ } : {},
3746
4036
  ...discovery ? {
3747
4037
  sitemap: {
3748
4038
  found: discovery.diagnostics.found,
@@ -4399,9 +4689,12 @@ var Logger = class {
4399
4689
  };
4400
4690
 
4401
4691
  export {
4692
+ RULES,
4693
+ SITE_RULES,
4402
4694
  DEFAULT_PROFILE,
4403
4695
  PROFILES,
4404
4696
  PROFILE_NAMES,
4697
+ PROSE_RULES,
4405
4698
  routeKey,
4406
4699
  targetKey,
4407
4700
  fingerprint,
@@ -4414,4 +4707,4 @@ export {
4414
4707
  summarize,
4415
4708
  Logger
4416
4709
  };
4417
- //# sourceMappingURL=chunk-5FO2Q3II.js.map
4710
+ //# sourceMappingURL=chunk-SKX7UCGC.js.map