@goflag/cli 0.2.8 → 0.2.10

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.
@@ -816,53 +816,111 @@ async function probeManifest(href, options = {}) {
816
816
  }
817
817
  }
818
818
 
819
+ // src/lib/core/robots/parse.ts
820
+ var RULE_DIRECTIVES = /* @__PURE__ */ new Set(["allow", "disallow"]);
821
+ var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set(["crawl-delay", "host", "clean-param", "request-rate", "noindex"]);
822
+ function parseRobots(body) {
823
+ const groups = [];
824
+ const sitemaps = [];
825
+ const invalidLines = [];
826
+ const unknownDirectives = [];
827
+ let current;
828
+ let currentTookRule = false;
829
+ const lines = body.split(/\r\n|\r|\n/);
830
+ for (const [index, rawLine] of lines.entries()) {
831
+ const line = index + 1;
832
+ const withoutComment = rawLine.replace(/#.*$/, "");
833
+ const trimmed = withoutComment.trim();
834
+ if (trimmed.length === 0) continue;
835
+ const separator = trimmed.indexOf(":");
836
+ if (separator === -1) {
837
+ invalidLines.push({ line, raw: rawLine, reason: "no `:` separator" });
838
+ continue;
839
+ }
840
+ const name = trimmed.slice(0, separator).trim().toLowerCase();
841
+ const value = trimmed.slice(separator + 1).trim();
842
+ if (name === "user-agent") {
843
+ if (value.length === 0) {
844
+ invalidLines.push({ line, raw: rawLine, reason: "empty `User-agent`" });
845
+ continue;
846
+ }
847
+ if (!current || currentTookRule) {
848
+ current = { userAgents: [], rules: [] };
849
+ groups.push(current);
850
+ currentTookRule = false;
851
+ }
852
+ current.userAgents.push({ value: value.toLowerCase(), line });
853
+ continue;
854
+ }
855
+ if (name === "sitemap") {
856
+ if (value.length === 0) {
857
+ invalidLines.push({ line, raw: rawLine, reason: "empty `Sitemap`" });
858
+ continue;
859
+ }
860
+ sitemaps.push({ value, line });
861
+ continue;
862
+ }
863
+ if (RULE_DIRECTIVES.has(name)) {
864
+ if (!current) {
865
+ invalidLines.push({
866
+ line,
867
+ raw: rawLine,
868
+ reason: `\`${name}\` before any \`User-agent\` group`
869
+ });
870
+ continue;
871
+ }
872
+ current.rules.push({ kind: name, pattern: value, line });
873
+ currentTookRule = true;
874
+ continue;
875
+ }
876
+ if (KNOWN_EXTENSIONS.has(name)) {
877
+ unknownDirectives.push({ name, line });
878
+ continue;
879
+ }
880
+ invalidLines.push({ line, raw: rawLine, reason: `unknown directive \`${name}\`` });
881
+ }
882
+ return { groups, sitemaps, invalidLines, unknownDirectives };
883
+ }
884
+
819
885
  // src/lib/core/probes/robots.ts
820
886
  async function probeRobots(origin, options = {}) {
821
887
  const url = new URL("/robots.txt", origin).toString();
822
888
  const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 5e3);
889
+ const empty = { groups: [], sitemaps: [], invalidLines: [], unknownDirectives: [] };
890
+ const noRedirect = { count: 0, finalUrl: url, crossOrigin: false };
823
891
  try {
824
892
  const res = await fetch(url, { signal, redirect: "follow" });
893
+ const redirected = res.url !== url;
894
+ const redirects = {
895
+ count: redirected ? 1 : 0,
896
+ finalUrl: res.url || url,
897
+ crossOrigin: redirected && !sameOrigin(res.url, url)
898
+ };
825
899
  if (!res.ok) {
826
- return { url, status: res.status, found: false, sitemaps: [], blocksAll: false };
900
+ return { url, status: res.status, found: false, byteLength: 0, redirects, ...empty };
827
901
  }
828
- const body = await res.text();
902
+ const raw = await res.text();
829
903
  return {
830
904
  url,
831
905
  status: res.status,
832
906
  found: true,
833
- raw: body,
834
- sitemaps: extractSitemaps(body),
835
- blocksAll: blocksAllUserAgents(body)
907
+ raw,
908
+ byteLength: Buffer.byteLength(raw, "utf8"),
909
+ redirects,
910
+ ...parseRobots(raw)
836
911
  };
837
912
  } catch {
838
- return { url, status: 0, found: false, sitemaps: [], blocksAll: false };
913
+ return { url, status: 0, found: false, byteLength: 0, redirects: noRedirect, ...empty };
839
914
  } finally {
840
915
  cleanup();
841
916
  }
842
917
  }
843
- function extractSitemaps(body) {
844
- const out = [];
845
- for (const line of body.split(/\r?\n/)) {
846
- const m = /^\s*Sitemap:\s*(\S+)\s*$/i.exec(line);
847
- if (m?.[1]) out.push(m[1]);
848
- }
849
- return out;
850
- }
851
- function blocksAllUserAgents(body) {
852
- let inWildcard = false;
853
- for (const rawLine of body.split(/\r?\n/)) {
854
- const line = rawLine.replace(/#.*$/, "").trim();
855
- if (line.length === 0) continue;
856
- const ua = /^User-agent:\s*(.+)$/i.exec(line);
857
- if (ua) {
858
- inWildcard = ua[1].trim() === "*";
859
- continue;
860
- }
861
- if (!inWildcard) continue;
862
- const dis = /^Disallow:\s*(.*)$/i.exec(line);
863
- if (dis && dis[1].trim() === "/") return true;
918
+ function sameOrigin(a, b) {
919
+ try {
920
+ return new URL(a).origin === new URL(b).origin;
921
+ } catch {
922
+ return true;
864
923
  }
865
- return false;
866
924
  }
867
925
 
868
926
  // src/lib/core/probes/sitemap.ts
@@ -1293,7 +1351,14 @@ function findingsToIssues(result, rules) {
1293
1351
  severity: finding.severity ?? "info",
1294
1352
  message: finding.message ?? `Expected ${finding.expected}.`,
1295
1353
  origin: finding.origin,
1296
- fix: byId.get(finding.ruleId)?.fix
1354
+ fix: byId.get(finding.ruleId)?.fix,
1355
+ // The finding has known all four since it was evaluated; this is where
1356
+ // they used to stop. A report that drops the rigor asks every consumer
1357
+ // to re-derive it from a registry it does not have.
1358
+ rigor: finding.rigor,
1359
+ sources: finding.sources,
1360
+ observed: finding.observed,
1361
+ expected: finding.expected
1297
1362
  });
1298
1363
  }
1299
1364
  for (const crash of result.crashes) {
@@ -1311,10 +1376,77 @@ var TITLE_IDEAL = [10, 60];
1311
1376
  var TITLE_ACCEPTABLE = [5, 70];
1312
1377
  var DESC_IDEAL = [50, 160];
1313
1378
  var DESC_ACCEPTABLE = [25, 200];
1379
+ var RATIO_IDEAL = [1.7, 2.1];
1380
+ var RATIO_ACCEPTABLE = [1, 3];
1314
1381
  function tokens(value) {
1315
1382
  if (!value) return [];
1316
1383
  return value.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
1317
1384
  }
1385
+ function isAbsoluteHttpUrl(value) {
1386
+ try {
1387
+ const url = new URL(value);
1388
+ return url.protocol === "http:" || url.protocol === "https:";
1389
+ } catch {
1390
+ return false;
1391
+ }
1392
+ }
1393
+ function localeParts(tag) {
1394
+ const subtags = tag.trim().toLowerCase().split(/[-_]/).filter(Boolean);
1395
+ const language = subtags[0];
1396
+ if (!language || !/^[a-z]{2,3}$/.test(language)) return void 0;
1397
+ const region = subtags.slice(1).find((s) => /^([a-z]{2}|\d{3})$/.test(s));
1398
+ return region ? { language, region } : { language };
1399
+ }
1400
+ function sameLocale(a, b) {
1401
+ const left = localeParts(a);
1402
+ const right = localeParts(b);
1403
+ if (!left || !right || left.language !== right.language) return false;
1404
+ return !left.region || !right.region || left.region === right.region;
1405
+ }
1406
+ function hreflangCluster(ex) {
1407
+ const seen = /* @__PURE__ */ new Set();
1408
+ for (const annotation of ex.links.hreflang) {
1409
+ if (annotation.isXDefault) continue;
1410
+ const parts = localeParts(annotation.hreflang);
1411
+ if (!parts) continue;
1412
+ seen.add(parts.region ? `${parts.language}-${parts.region}` : parts.language);
1413
+ }
1414
+ return [...seen];
1415
+ }
1416
+ function describeAsset(status, contentType) {
1417
+ if (status === 0) return "the request failed";
1418
+ if (status >= 400 || status < 200) return `HTTP ${status}`;
1419
+ return `HTTP ${status} with \`${contentType ?? "no content type"}\`, which is not an image`;
1420
+ }
1421
+ var ICON_RELS = /* @__PURE__ */ new Set([
1422
+ "icon",
1423
+ "shortcut icon",
1424
+ "apple-touch-icon",
1425
+ "apple-touch-icon-precomposed",
1426
+ "mask-icon"
1427
+ ]);
1428
+ var APPLE_ICON_RELS = /* @__PURE__ */ new Set(["apple-touch-icon", "apple-touch-icon-precomposed"]);
1429
+ function declaredIcons(ex) {
1430
+ return ex.links.icons.filter((icon) => ICON_RELS.has(icon.rel.trim().toLowerCase()));
1431
+ }
1432
+ function normalise(value) {
1433
+ return value.trim().toLowerCase().split(/\s+/).sort().join(" ");
1434
+ }
1435
+ function sameAsset(href, src) {
1436
+ const pathOf = (value) => {
1437
+ try {
1438
+ return new URL(value, "https://goflag.invalid").pathname;
1439
+ } catch {
1440
+ return value.split("?")[0] ?? value;
1441
+ }
1442
+ };
1443
+ return pathOf(href) === pathOf(src);
1444
+ }
1445
+ function hasOpenGraph(ex) {
1446
+ return Boolean(
1447
+ ex.openGraph.title?.value || ex.openGraph.images.length > 0 || ex.openGraph.url?.value
1448
+ );
1449
+ }
1318
1450
  var titleMissing = {
1319
1451
  id: "title.missing",
1320
1452
  kind: "boolean",
@@ -1569,10 +1701,7 @@ var ogDescriptionMissing = {
1569
1701
  evaluate: (ex) => {
1570
1702
  const observed = ex.openGraph.description?.value?.trim();
1571
1703
  if (observed) return { status: "pass", observed };
1572
- const hasOtherOg = Boolean(
1573
- ex.openGraph.title?.value || ex.openGraph.images.length > 0 || ex.openGraph.url?.value
1574
- );
1575
- if (!hasOtherOg) return { status: "na", observed: null };
1704
+ if (!hasOpenGraph(ex)) return { status: "na", observed: null };
1576
1705
  return {
1577
1706
  status: "fail",
1578
1707
  observed: null,
@@ -1616,6 +1745,476 @@ var ogImageMissing = {
1616
1745
  };
1617
1746
  }
1618
1747
  };
1748
+ var ogImageAbsolute = {
1749
+ id: "og.image.absolute",
1750
+ kind: "boolean",
1751
+ category: "opengraph",
1752
+ // The same severity as `og.image.missing`, deliberately: the unfurl is
1753
+ // identical either way, and the registry should not rank the broken
1754
+ // declaration above the absent one when their effect is the same.
1755
+ severity: "warning",
1756
+ title: "`og:image` must be an absolute URL",
1757
+ why: "Unlike a browser, the crawler that builds the preview has no document to resolve a relative path against \u2014 it reads the tag, not the page. A relative `og:image` is therefore not a smaller mistake than a missing one: the unfurl is identical, and the tag makes it look handled.",
1758
+ rigor: "vendor-spec",
1759
+ sources: ["ogp", "meta-og-sharing"],
1760
+ reads: ["openGraph.images"],
1761
+ expected: "every `og:image` URL absolute, scheme included",
1762
+ relates: ["og.image.missing", "canonical.absolute"],
1763
+ fix: {
1764
+ title: "Give Next a metadataBase, or write the origin in",
1765
+ snippet: [
1766
+ "// app/layout.tsx \u2014 every relative metadata URL resolves against this.",
1767
+ "export const metadata = {",
1768
+ " metadataBase: new URL(process.env.SITE_ORIGIN!),",
1769
+ ' openGraph: { images: ["/og.png"] }, // emitted absolute',
1770
+ "};"
1771
+ ].join("\n"),
1772
+ language: "tsx"
1773
+ },
1774
+ evaluate: (ex) => {
1775
+ if (ex.openGraph.images.length === 0) return { status: "na", observed: null };
1776
+ const declarations = [];
1777
+ for (const image of ex.openGraph.images) {
1778
+ declarations.push({ property: "og:image", value: image.url.value.trim() });
1779
+ if (image.secureUrl) {
1780
+ declarations.push({
1781
+ property: "og:image:secure_url",
1782
+ value: image.secureUrl.value.trim()
1783
+ });
1784
+ }
1785
+ }
1786
+ const observed = declarations.map((d) => d.value);
1787
+ const relative = declarations.filter((d) => !isAbsoluteHttpUrl(d.value));
1788
+ if (relative.length === 0) return { status: "pass", observed };
1789
+ return {
1790
+ status: "fail",
1791
+ observed,
1792
+ message: `Open Graph image URL is not absolute: ${relative.map((d) => `\`${d.property}\` = \`${d.value}\``).join(", ")}. Crawlers cannot resolve it.`,
1793
+ origin: { kind: "meta", property: relative[0].property }
1794
+ };
1795
+ }
1796
+ };
1797
+ var ogImageAlt = {
1798
+ id: "og.image.alt",
1799
+ kind: "boolean",
1800
+ category: "opengraph",
1801
+ severity: "warning",
1802
+ title: "Describe the shared image with `og:image:alt`",
1803
+ why: "The protocol defines `og:image:alt` as a description of what is in the image, and a generated card usually carries the page's title as pixels. Omitting it leaves that text sighted-only, in the one place a link is seen before anybody has opened the page. Optional in the vocabulary, which is why this is a guideline and not a spec violation.",
1804
+ rigor: "guideline",
1805
+ sources: ["ogp"],
1806
+ reads: ["openGraph.images"],
1807
+ expected: "an `og:image:alt` beside every `og:image`",
1808
+ relates: ["og.image.missing"],
1809
+ fix: {
1810
+ title: "Carry the alt per image, not as a constant",
1811
+ snippet: [
1812
+ "// app/\u2026/opengraph-image.tsx \u2014 `export const alt` is one fixed string, so a",
1813
+ "// translated or data-derived description has to go through the metadata fn.",
1814
+ "export async function generateImageMetadata({ params }) {",
1815
+ " const t = await getTranslations({ locale: params.locale });",
1816
+ ' return [{ id: "og", size, contentType, alt: t("og.alt") }];',
1817
+ "}"
1818
+ ].join("\n"),
1819
+ language: "tsx"
1820
+ },
1821
+ evaluate: (ex) => {
1822
+ if (ex.openGraph.images.length === 0) return { status: "na", observed: null };
1823
+ const observed = ex.openGraph.images.map((image) => ({
1824
+ url: image.url.value,
1825
+ alt: image.alt?.value ?? null
1826
+ }));
1827
+ const undescribed = observed.filter((image) => !image.alt?.trim());
1828
+ if (undescribed.length === 0) return { status: "pass", observed };
1829
+ return {
1830
+ status: "fail",
1831
+ observed,
1832
+ message: undescribed.length === observed.length ? "Page declares an `og:image` with no `og:image:alt`." : `${undescribed.length} of ${observed.length} \`og:image\` declarations have no \`og:image:alt\`.`,
1833
+ origin: { kind: "meta", property: "og:image:alt" }
1834
+ };
1835
+ }
1836
+ };
1837
+ var ogImageDimensions = {
1838
+ id: "og.image.dimensions",
1839
+ kind: "boolean",
1840
+ category: "opengraph",
1841
+ severity: "warning",
1842
+ title: "Declare `og:image:width` and `og:image:height`",
1843
+ why: "A crawler that does not know the size cannot lay the card out until it has fetched the image, so the first share of a URL renders without it \u2014 the one share that matters most. The dimensions are the fix, and they cost two tags.",
1844
+ rigor: "vendor-spec",
1845
+ sources: ["ogp", "meta-og-sharing"],
1846
+ reads: ["openGraph.images"],
1847
+ expected: "both `og:image:width` and `og:image:height` on every image",
1848
+ relates: ["og.image.missing", "og.image.ratio"],
1849
+ fix: {
1850
+ title: "Declare the size alongside the URL",
1851
+ snippet: [
1852
+ "// app/\u2026/page.tsx",
1853
+ "export const metadata = {",
1854
+ ' openGraph: { images: [{ url: "/og.png", width: 1200, height: 630 }] },',
1855
+ "};",
1856
+ "// The file convention emits both from `export const size` for free."
1857
+ ].join("\n"),
1858
+ language: "tsx"
1859
+ },
1860
+ evaluate: (ex) => {
1861
+ if (ex.openGraph.images.length === 0) return { status: "na", observed: null };
1862
+ const observed = ex.openGraph.images.map((image) => ({
1863
+ url: image.url.value,
1864
+ width: image.width?.value ?? null,
1865
+ height: image.height?.value ?? null
1866
+ }));
1867
+ const incomplete = observed.filter((image) => image.width === null || image.height === null);
1868
+ if (incomplete.length === 0) return { status: "pass", observed };
1869
+ return {
1870
+ status: "fail",
1871
+ observed,
1872
+ message: `${incomplete.length === observed.length ? "The" : `${incomplete.length} of ${observed.length}`} \`og:image\` declaration${incomplete.length === 1 ? "" : "s"} omit${incomplete.length === 1 ? "s" : ""} \`og:image:width\` or \`og:image:height\`.`,
1873
+ origin: { kind: "meta", property: "og:image:width" }
1874
+ };
1875
+ }
1876
+ };
1877
+ var ogImageRatio = {
1878
+ id: "og.image.ratio",
1879
+ kind: "scored",
1880
+ category: "opengraph",
1881
+ title: "Keep the shared image near the 1.91:1 the card is laid out for",
1882
+ why: "Meta's recommended 1200\xD7630 is a shape before it is a size. An image far from it is not rejected \u2014 it is cropped to fit, and the crop is chosen by the consumer rather than by you.",
1883
+ rigor: "vendor-spec",
1884
+ sources: ["meta-og-sharing", "x-cards"],
1885
+ reads: ["openGraph.images"],
1886
+ bands: { ideal: RATIO_IDEAL, acceptable: RATIO_ACCEPTABLE },
1887
+ severityByBand: { acceptable: "info", poor: "warning" },
1888
+ expected: `a width \xF7 height between ${RATIO_IDEAL[0]} and ${RATIO_IDEAL[1]} (1.91:1 is 1.9)`,
1889
+ relates: ["og.image.dimensions"],
1890
+ evaluate: (ex) => {
1891
+ const measured = ex.openGraph.images.find((image) => image.width?.value && image.height?.value);
1892
+ if (!measured) return { status: "na", observed: 0 };
1893
+ const ratio = Math.round(measured.width.value / measured.height.value * 100) / 100;
1894
+ const { status, band } = bandFor(ratio, { ideal: RATIO_IDEAL, acceptable: RATIO_ACCEPTABLE });
1895
+ if (status === "pass") return { status, band, observed: ratio };
1896
+ return {
1897
+ status,
1898
+ band,
1899
+ observed: ratio,
1900
+ message: `\`og:image\` is ${measured.width.value}\xD7${measured.height.value} \u2014 a ratio of ${ratio}:1, against the 1.91:1 the preview card is laid out for. Consumers will crop it.`,
1901
+ origin: { kind: "meta", property: "og:image:width" }
1902
+ };
1903
+ }
1904
+ };
1905
+ var ogLocaleMissing = {
1906
+ id: "og.locale.missing",
1907
+ kind: "boolean",
1908
+ category: "opengraph",
1909
+ severity: "warning",
1910
+ title: "A translated page has to say which locale it is in",
1911
+ why: "`og:locale` defaults to `en_US` in the protocol itself \u2014 so a page that omits it does not decline to answer, it answers wrongly. On a site whose whole point is that the same page exists in several languages, every translation claims to be American English.",
1912
+ rigor: "vendor-spec",
1913
+ sources: ["ogp"],
1914
+ reads: ["openGraph.locale", "links.hreflang"],
1915
+ expected: "an `og:locale` on any page that declares hreflang alternates",
1916
+ relates: ["og.locale.alternates", "hreflang.missing"],
1917
+ fix: {
1918
+ title: "Emit the locale in the page's own metadata",
1919
+ snippet: [
1920
+ "// app/[locale]/\u2026/page.tsx",
1921
+ "export const metadata = {",
1922
+ ' openGraph: { locale: "fr_FR", alternateLocale: ["en_US", "es_ES"] },',
1923
+ "};"
1924
+ ].join("\n"),
1925
+ language: "tsx"
1926
+ },
1927
+ evaluate: (ex) => {
1928
+ const cluster = hreflangCluster(ex);
1929
+ const observed = ex.openGraph.locale?.value?.trim() ?? null;
1930
+ if (cluster.length < 2 || !hasOpenGraph(ex)) return { status: "na", observed };
1931
+ if (observed) return { status: "pass", observed };
1932
+ return {
1933
+ status: "fail",
1934
+ observed: null,
1935
+ message: `Page declares hreflang alternates (${cluster.join(", ")}) but no \`og:locale\`; the protocol default \`en_US\` applies instead.`,
1936
+ origin: { kind: "meta", property: "og:locale" }
1937
+ };
1938
+ }
1939
+ };
1940
+ var ogLocaleAlternates = {
1941
+ id: "og.locale.alternates",
1942
+ kind: "boolean",
1943
+ category: "opengraph",
1944
+ severity: "warning",
1945
+ title: "`og:locale:alternate` and the hreflang cluster must name the same locales",
1946
+ why: "Two vocabularies describe the same fact \u2014 this page exists in these languages \u2014 and nothing in a build keeps them in step, so they drift silently. Whichever one is short is the one telling a consumer that a translation it could have linked to does not exist.",
1947
+ rigor: "vendor-spec",
1948
+ sources: ["ogp", "google-hreflang"],
1949
+ reads: ["openGraph.locale", "openGraph.localeAlternates", "links.hreflang"],
1950
+ expected: "one `og:locale:alternate` per hreflang locale other than this page's own",
1951
+ relates: ["og.locale.missing", "hreflang.missing"],
1952
+ fix: {
1953
+ title: "Derive both lists from the same cluster",
1954
+ snippet: [
1955
+ "// The hreflang map and the OG locales are one fact stated twice.",
1956
+ "openGraph: {",
1957
+ " locale: ogLocale(locale),",
1958
+ " alternateLocale: cluster.filter((l) => l !== locale).map(ogLocale),",
1959
+ "},",
1960
+ "alternates: { languages: cluster },"
1961
+ ].join("\n"),
1962
+ language: "ts"
1963
+ },
1964
+ evaluate: (ex) => {
1965
+ const cluster = hreflangCluster(ex);
1966
+ const own = ex.openGraph.locale?.value?.trim();
1967
+ const declared = ex.openGraph.localeAlternates.map((a) => a.value.trim()).filter(Boolean);
1968
+ const observed = { locale: own ?? null, alternates: declared, hreflang: cluster };
1969
+ if (cluster.length < 2 || !own) return { status: "na", observed };
1970
+ const missing = cluster.filter(
1971
+ (tag) => !sameLocale(tag, own) && !declared.some((d) => sameLocale(d, tag))
1972
+ );
1973
+ const unbacked = declared.filter((d) => !cluster.some((tag) => sameLocale(tag, d)));
1974
+ if (missing.length === 0 && unbacked.length === 0) return { status: "pass", observed };
1975
+ const disagreements = [];
1976
+ if (missing.length > 0) {
1977
+ disagreements.push(`no \`og:locale:alternate\` for ${missing.join(", ")}`);
1978
+ }
1979
+ if (unbacked.length > 0) {
1980
+ disagreements.push(`\`og:locale:alternate\` ${unbacked.join(", ")} has no hreflang`);
1981
+ }
1982
+ return {
1983
+ status: "fail",
1984
+ observed,
1985
+ message: `Open Graph and hreflang disagree about this page's translations: ${disagreements.join("; ")}.`,
1986
+ origin: { kind: "meta", property: "og:locale:alternate" }
1987
+ };
1988
+ }
1989
+ };
1990
+ var iconsMissing = {
1991
+ id: "icons.missing",
1992
+ kind: "boolean",
1993
+ category: "icons",
1994
+ severity: "warning",
1995
+ title: "Declare an icon \u2014 the tab, the bookmark and the share sheet all read one",
1996
+ why: "No specification requires an icon, which is exactly why nothing complains and every consumer improvises. A browser falls back to `/favicon.ico` at the root, a feed reader or a link unfurler often falls back to nothing, and a bookmark to a site with no icon is a grey square among fifty.",
1997
+ rigor: "guideline",
1998
+ sources: ["whatwg-html-link-types", "mdn-link-rel"],
1999
+ reads: ["links.icons", "links.manifest"],
2000
+ expected: 'at least one `<link rel="icon">`, or icons declared by a manifest',
2001
+ relates: ["icons.apple-touch.missing", "icons.manifest-mismatch"],
2002
+ fix: {
2003
+ title: "Let the file convention declare it",
2004
+ snippet: [
2005
+ '// app/icon.svg (or icon.png) \u2014 Next emits the <link rel="icon"> itself.',
2006
+ '// app/apple-icon.png likewise emits rel="apple-touch-icon".',
2007
+ "// A /favicon.ico at the root is a separate matter: see icons.ico.missing."
2008
+ ].join("\n"),
2009
+ language: "tsx"
2010
+ },
2011
+ evaluate: (ex) => {
2012
+ const head = declaredIcons(ex);
2013
+ const fromManifest = ex.links.manifest?.icons ?? [];
2014
+ const observed = {
2015
+ head: head.map((icon) => ({ rel: icon.rel, href: icon.href })),
2016
+ manifest: fromManifest.map((icon) => icon.src)
2017
+ };
2018
+ if (head.length > 0 || fromManifest.length > 0) return { status: "pass", observed };
2019
+ return {
2020
+ status: "fail",
2021
+ observed,
2022
+ message: 'Page declares no icon: no `<link rel="icon">`, and no icons from a manifest. Consumers fall back to `/favicon.ico` if the site happens to serve one.',
2023
+ origin: { kind: "link", rel: "icon" }
2024
+ };
2025
+ }
2026
+ };
2027
+ var iconsAppleTouchMissing = {
2028
+ id: "icons.apple-touch.missing",
2029
+ kind: "boolean",
2030
+ category: "icons",
2031
+ severity: "info",
2032
+ title: "Declare an `apple-touch-icon` for the iOS home screen",
2033
+ why: 'iOS does not read `rel="icon"` for a home-screen shortcut. With no `apple-touch-icon` it screenshots the page instead, so what someone saved is a thumbnail of whatever was on screen when they saved it.',
2034
+ rigor: "vendor-spec",
2035
+ sources: ["apple-web-apps", "whatwg-html-link-types"],
2036
+ reads: ["links.icons", "links.manifest"],
2037
+ expected: 'a `<link rel="apple-touch-icon">`',
2038
+ relates: ["icons.missing"],
2039
+ evaluate: (ex) => {
2040
+ const head = declaredIcons(ex);
2041
+ if (head.length === 0) return { status: "na", observed: null };
2042
+ const apple = head.filter((icon) => APPLE_ICON_RELS.has(icon.rel.trim().toLowerCase()));
2043
+ const observed = head.map((icon) => icon.rel);
2044
+ if (apple.length > 0) return { status: "pass", observed };
2045
+ return {
2046
+ status: "fail",
2047
+ observed,
2048
+ message: `Page declares icons (${observed.join(", ")}) but no \`apple-touch-icon\`; iOS will screenshot the page instead.`,
2049
+ origin: { kind: "link", rel: "apple-touch-icon" }
2050
+ };
2051
+ }
2052
+ };
2053
+ var iconsManifestMismatch = {
2054
+ id: "icons.manifest-mismatch",
2055
+ kind: "boolean",
2056
+ category: "icons",
2057
+ severity: "info",
2058
+ title: "The manifest and the `<head>` must not disagree about the same icon",
2059
+ why: "Two files declare the icons, maintained by different hands, and nothing compares them. The failure worth naming is not that the two lists differ \u2014 they legitimately do, since an apple-touch-icon is not a manifest icon \u2014 but that they describe the *same file* differently, or that the icons exist only in the manifest, where a browser tab never looks.",
2060
+ rigor: "guideline",
2061
+ sources: ["w3c-appmanifest", "whatwg-html-link-types"],
2062
+ reads: ["links.icons", "links.manifest"],
2063
+ expected: "the same size and type wherever an icon is declared twice",
2064
+ relates: ["icons.missing"],
2065
+ evaluate: (ex) => {
2066
+ const manifest = ex.links.manifest;
2067
+ if (!manifest?.parsed || !manifest.icons || manifest.icons.length === 0) {
2068
+ return { status: "na", observed: null };
2069
+ }
2070
+ const head = declaredIcons(ex);
2071
+ const observed = {
2072
+ head: head.map((icon) => ({ href: icon.href, sizes: icon.sizes ?? null })),
2073
+ manifest: manifest.icons.map((icon) => ({ src: icon.src, sizes: icon.sizes ?? null }))
2074
+ };
2075
+ if (head.length === 0) {
2076
+ return {
2077
+ status: "fail",
2078
+ observed,
2079
+ message: `Manifest declares ${manifest.icons.length} icon(s) but the \`<head>\` declares none; a browser tab reads the \`<head>\`, not the manifest.`,
2080
+ origin: { kind: "link", rel: "manifest" }
2081
+ };
2082
+ }
2083
+ const disagreements = [];
2084
+ for (const icon of manifest.icons) {
2085
+ const twin = head.find((candidate) => sameAsset(candidate.href, icon.src));
2086
+ if (!twin) continue;
2087
+ if (icon.sizes && twin.sizes && normalise(icon.sizes) !== normalise(twin.sizes)) {
2088
+ disagreements.push(
2089
+ `\`${icon.src}\` is \`${twin.sizes}\` in the \`<head>\` and \`${icon.sizes}\` in the manifest`
2090
+ );
2091
+ }
2092
+ if (icon.type && twin.type && icon.type.toLowerCase() !== twin.type.toLowerCase()) {
2093
+ disagreements.push(
2094
+ `\`${icon.src}\` is \`${twin.type}\` in the \`<head>\` and \`${icon.type}\` in the manifest`
2095
+ );
2096
+ }
2097
+ }
2098
+ if (disagreements.length === 0) return { status: "pass", observed };
2099
+ return {
2100
+ status: "fail",
2101
+ observed,
2102
+ message: `The manifest and the \`<head>\` describe the same icon differently: ${disagreements.join("; ")}.`,
2103
+ origin: { kind: "link", rel: "manifest" }
2104
+ };
2105
+ }
2106
+ };
2107
+ var ogImageReachable = {
2108
+ id: "og.image.reachable",
2109
+ kind: "boolean",
2110
+ category: "opengraph",
2111
+ severity: "error",
2112
+ title: "The URL in `og:image` has to answer with an image",
2113
+ why: "Every other check on a preview image judges the tag. This one judges the file, and it is the only one that can catch the failure with no symptom: a URL that is present, well-formed, absolute \u2014 and dead. The card renders empty and nothing in the page says why. Found on this project's own documentation, where eleven pages pointed at a route a redirect had been swallowing since the day it was written.",
2114
+ rigor: "vendor-spec",
2115
+ sources: ["ogp", "meta-og-sharing"],
2116
+ reads: ["openGraph.images", "assets"],
2117
+ expected: "a 2xx image at every `og:image` URL",
2118
+ relates: ["og.image.missing", "og.image.absolute"],
2119
+ evaluate: (ex) => {
2120
+ if (!ex.assets) return { status: "na", observed: null };
2121
+ const probed = ex.openGraph.images.map((image) => ex.assets?.[image.url.value.trim()]).filter((probe) => probe !== void 0);
2122
+ if (probed.length === 0) return { status: "na", observed: null };
2123
+ const observed = probed.map((probe) => ({
2124
+ status: probe.status,
2125
+ contentType: probe.contentType ?? null
2126
+ }));
2127
+ const dead = probed.filter((probe) => !probe.ok);
2128
+ if (dead.length === 0) return { status: "pass", observed };
2129
+ return {
2130
+ status: "fail",
2131
+ observed,
2132
+ message: `\`og:image\` does not serve an image: ${dead.map((probe) => describeAsset(probe.status, probe.contentType)).join("; ")}. The preview card will render without it.`,
2133
+ origin: { kind: "meta", property: "og:image" }
2134
+ };
2135
+ }
2136
+ };
2137
+ var iconsUnreachable = {
2138
+ id: "icons.unreachable",
2139
+ kind: "boolean",
2140
+ category: "icons",
2141
+ severity: "warning",
2142
+ title: "A declared icon has to answer with an image",
2143
+ why: "An icon that 404s is worse than one never declared: the client asks, gets nothing, and has already skipped the `/favicon.ico` it would have fallen back to. The declaration is what took the fallback away.",
2144
+ rigor: "vendor-spec",
2145
+ sources: ["whatwg-html-link-types", "mdn-link-rel"],
2146
+ reads: ["links.icons", "assets"],
2147
+ expected: "a 2xx image at every declared icon URL",
2148
+ relates: ["icons.missing", "icons.ico.missing"],
2149
+ evaluate: (ex) => {
2150
+ if (!ex.assets) return { status: "na", observed: null };
2151
+ const probed = declaredIcons(ex).map((icon) => ({ icon, probe: ex.assets?.[icon.href] })).filter(
2152
+ (pair) => Boolean(pair.probe)
2153
+ );
2154
+ if (probed.length === 0) return { status: "na", observed: null };
2155
+ const observed = probed.map(({ icon, probe }) => ({
2156
+ rel: icon.rel,
2157
+ href: icon.href,
2158
+ status: probe.status
2159
+ }));
2160
+ const dead = probed.filter(({ probe }) => !probe.ok);
2161
+ if (dead.length === 0) return { status: "pass", observed };
2162
+ return {
2163
+ status: "fail",
2164
+ observed,
2165
+ message: `Declared icon does not serve an image: ${dead.map(
2166
+ ({ icon, probe }) => `\`${icon.rel}\` \u2192 \`${icon.href}\` (${describeAsset(probe.status, probe.contentType)})`
2167
+ ).join("; ")}.`,
2168
+ origin: { kind: "link", rel: "icon" }
2169
+ };
2170
+ }
2171
+ };
2172
+ var iconsSizesMismatch = {
2173
+ id: "icons.sizes-mismatch",
2174
+ kind: "boolean",
2175
+ category: "icons",
2176
+ severity: "info",
2177
+ title: "A `sizes` attribute must describe the file it points at",
2178
+ why: "`sizes` is how a client picks one icon out of several without fetching them all, so a wrong value costs exactly what the attribute was there to save: the client downloads the file it was told to prefer and gets the wrong resolution. A half-true declaration is the common shape \u2014 a `.ico` carrying 16, 32 and 48 declared as `48x48` advertises one third of itself.",
2179
+ rigor: "guideline",
2180
+ sources: ["whatwg-html-link-types", "mdn-link-rel"],
2181
+ reads: ["links.icons", "assets"],
2182
+ expected: "`sizes` listing what the file actually contains",
2183
+ relates: ["icons.unreachable"],
2184
+ evaluate: (ex) => {
2185
+ if (!ex.assets) return { status: "na", observed: null };
2186
+ const comparable = declaredIcons(ex).map((icon) => ({ icon, probe: ex.assets?.[icon.href] })).filter(
2187
+ (pair) => (
2188
+ // Only where both sides said something. `any` declares no dimension
2189
+ // to check, and a format goflag does not decode leaves `sizes`
2190
+ // absent — which means unknown, and unknown is not a mismatch.
2191
+ Boolean(pair.probe?.ok && pair.probe.sizes?.length) && pair.icon.parsedSizes.some((size) => size !== "any")
2192
+ )
2193
+ );
2194
+ if (comparable.length === 0) return { status: "na", observed: null };
2195
+ const observed = comparable.map(({ icon, probe }) => ({
2196
+ href: icon.href,
2197
+ declared: icon.sizes ?? null,
2198
+ actual: (probe.sizes ?? []).map((size) => `${size.width}x${size.height}`)
2199
+ }));
2200
+ const wrong = observed.filter((entry) => {
2201
+ const declared = new Set(
2202
+ comparable.find((pair) => pair.icon.href === entry.href).icon.parsedSizes.filter((size) => size !== "any").map((size) => `${size.width}x${size.height}`)
2203
+ );
2204
+ const actual = new Set(entry.actual);
2205
+ return declared.size !== actual.size || [...declared].some((size) => !actual.has(size));
2206
+ });
2207
+ if (wrong.length === 0) return { status: "pass", observed };
2208
+ return {
2209
+ status: "fail",
2210
+ observed,
2211
+ message: `\`sizes\` does not describe the file: ${wrong.map(
2212
+ (entry) => `\`${entry.href}\` declares \`${entry.declared}\` and contains ${entry.actual.join(", ")}`
2213
+ ).join("; ")}.`,
2214
+ origin: { kind: "link", rel: "icon" }
2215
+ };
2216
+ }
2217
+ };
1619
2218
  var robotsConflict = {
1620
2219
  id: "robots.conflict",
1621
2220
  kind: "boolean",
@@ -1677,8 +2276,20 @@ var RULES = [
1677
2276
  canonicalMissing,
1678
2277
  descriptionLength,
1679
2278
  descriptionMissing,
2279
+ iconsAppleTouchMissing,
2280
+ iconsManifestMismatch,
2281
+ iconsMissing,
2282
+ iconsSizesMismatch,
2283
+ iconsUnreachable,
1680
2284
  ogDescriptionMissing,
2285
+ ogImageAbsolute,
2286
+ ogImageAlt,
2287
+ ogImageDimensions,
1681
2288
  ogImageMissing,
2289
+ ogImageRatio,
2290
+ ogImageReachable,
2291
+ ogLocaleAlternates,
2292
+ ogLocaleMissing,
1682
2293
  ogTitleMissing,
1683
2294
  robotsConflict,
1684
2295
  titleLength,
@@ -1720,11 +2331,44 @@ function openGraphFromPage(page) {
1720
2331
  other: og.unknown
1721
2332
  };
1722
2333
  }
1723
- function linksFromPage(page) {
1724
- return {
1725
- hreflang: page.links.alternates,
1726
- icons: page.links.icons,
1727
- manifest: page.links.manifest ? { href: page.links.manifest.href } : void 0,
2334
+ function optionalString(value) {
2335
+ return typeof value === "string" && value.trim() ? value : void 0;
2336
+ }
2337
+ function manifestIcons(data) {
2338
+ if (typeof data !== "object" || data === null) return [];
2339
+ const raw = data.icons;
2340
+ if (!Array.isArray(raw)) return [];
2341
+ const icons = [];
2342
+ for (const entry of raw) {
2343
+ if (typeof entry !== "object" || entry === null) continue;
2344
+ const { src, sizes, type, purpose } = entry;
2345
+ const source = optionalString(src);
2346
+ if (!source) continue;
2347
+ icons.push({
2348
+ src: source,
2349
+ sizes: optionalString(sizes),
2350
+ type: optionalString(type),
2351
+ purpose: optionalString(purpose),
2352
+ parsedSizes: parseSizes(optionalString(sizes))
2353
+ });
2354
+ }
2355
+ return icons;
2356
+ }
2357
+ function manifestFromPage(page) {
2358
+ const link = page.links.manifest;
2359
+ if (!link) return void 0;
2360
+ const probe = page.probes.manifest;
2361
+ if (!probe) return { href: link.href };
2362
+ if (!probe.found || probe.parseError !== void 0) {
2363
+ return { href: link.href, parsed: false };
2364
+ }
2365
+ return { href: link.href, parsed: true, icons: manifestIcons(probe.data) };
2366
+ }
2367
+ function linksFromPage(page) {
2368
+ return {
2369
+ hreflang: page.links.alternates,
2370
+ icons: page.links.icons,
2371
+ manifest: manifestFromPage(page),
1728
2372
  feeds: page.links.feeds.map(({ type, href, title }) => ({ type, href, title }))
1729
2373
  };
1730
2374
  }
@@ -1761,6 +2405,7 @@ function extractionFromPage(page) {
1761
2405
  keywords: page.meta.keywords
1762
2406
  },
1763
2407
  openGraph: openGraphFromPage(page),
2408
+ ...page.assets ? { assets: page.assets } : {},
1764
2409
  twitter: {
1765
2410
  card: page.twitter.card,
1766
2411
  site: page.twitter.site,
@@ -2014,6 +2659,70 @@ function sortLocales(locales) {
2014
2659
  return [...xDefault, ...rest];
2015
2660
  }
2016
2661
 
2662
+ // src/lib/core/robots/match.ts
2663
+ function robotsAllows(groups, path, userAgent = "*") {
2664
+ const group = selectGroup(groups, userAgent);
2665
+ if (!group) return { allowed: true };
2666
+ const token = group.userAgents[0]?.value ?? "*";
2667
+ let best;
2668
+ let bestLength = -1;
2669
+ for (const rule of group.rules) {
2670
+ if (rule.pattern.length === 0) continue;
2671
+ if (!patternMatches(rule.pattern, path)) continue;
2672
+ const length = octetLength(rule.pattern);
2673
+ if (length > bestLength) {
2674
+ best = rule;
2675
+ bestLength = length;
2676
+ continue;
2677
+ }
2678
+ if (length === bestLength && rule.kind === "allow") best = rule;
2679
+ }
2680
+ if (!best) return { allowed: true, group: token };
2681
+ return { allowed: best.kind === "allow", rule: best, group: token };
2682
+ }
2683
+ function selectGroup(groups, userAgent) {
2684
+ const wanted = userAgent.trim().toLowerCase();
2685
+ let bestToken;
2686
+ for (const group of groups) {
2687
+ for (const agent of group.userAgents) {
2688
+ if (agent.value === "*") continue;
2689
+ if (!wanted.startsWith(agent.value)) continue;
2690
+ if (bestToken === void 0 || agent.value.length > bestToken.length) {
2691
+ bestToken = agent.value;
2692
+ }
2693
+ }
2694
+ }
2695
+ const token = bestToken ?? "*";
2696
+ const matching = groups.filter(
2697
+ (group) => group.userAgents.some((agent) => agent.value === token)
2698
+ );
2699
+ if (matching.length === 0) return void 0;
2700
+ if (matching.length === 1) return matching[0];
2701
+ return {
2702
+ userAgents: matching.flatMap((group) => group.userAgents),
2703
+ rules: matching.flatMap((group) => group.rules)
2704
+ };
2705
+ }
2706
+ function patternMatches(pattern, path) {
2707
+ const anchored = pattern.endsWith("$");
2708
+ const body = anchored ? pattern.slice(0, -1) : pattern;
2709
+ const source = body.split("*").map((literal) => escapeRegExp(normalizeOctets(literal))).join(".*");
2710
+ return new RegExp(`^${source}${anchored ? "$" : ""}`).test(normalizeOctets(path));
2711
+ }
2712
+ function normalizeOctets(value) {
2713
+ try {
2714
+ return decodeURIComponent(value);
2715
+ } catch {
2716
+ return value;
2717
+ }
2718
+ }
2719
+ function octetLength(pattern) {
2720
+ return Buffer.byteLength(normalizeOctets(pattern), "utf8");
2721
+ }
2722
+ function escapeRegExp(value) {
2723
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2724
+ }
2725
+
2017
2726
  // src/lib/rules/site-rules.ts
2018
2727
  function isMultilingual(site) {
2019
2728
  return site.localeAxis.multilingual;
@@ -2155,7 +2864,9 @@ var robotsBlocksSite = {
2155
2864
  id: "robots.blocks-site",
2156
2865
  severity: "error",
2157
2866
  summary: "`robots.txt` must not forbid crawling a site that asks to be indexed",
2158
- appliesTo: (site) => site.robots?.found === true && site.robots.blocksAll,
2867
+ rigor: "vendor-spec",
2868
+ sources: ["ietf-rfc9309", "google-robots-intro"],
2869
+ appliesTo: (site) => site.robots?.found === true && !robotsAllows(site.robots.groups, "/").allowed,
2159
2870
  check: ({ site, issue }) => {
2160
2871
  const robotsUrl = site.robots?.url ?? `${site.origin}/robots.txt`;
2161
2872
  const asking = site.pages.filter((page) => metaRobotsTokens(page).has("index"));
@@ -2185,10 +2896,620 @@ var robotsBlocksSite = {
2185
2896
  });
2186
2897
  }
2187
2898
  };
2899
+ var iconsIcoMissing = {
2900
+ id: "icons.ico.missing",
2901
+ severity: "info",
2902
+ summary: "Serve a `/favicon.ico` at the root for the clients that ask blind",
2903
+ rigor: "guideline",
2904
+ sources: ["whatwg-html-link-types", "mdn-link-rel"],
2905
+ // Only judged when the probe actually ran. No probe means goflag did not
2906
+ // look, and a rule that reported an absence it never checked for would be
2907
+ // inventing a finding.
2908
+ appliesTo: (site) => site.favicon !== void 0,
2909
+ check: ({ site, issue }) => {
2910
+ const probe = site.favicon;
2911
+ if (!probe || probe.found) return [];
2912
+ const detail = probe.status === 0 ? "the request failed outright" : probe.status >= 400 ? `the origin answered ${probe.status}` : `the origin answered ${probe.status} with \`${probe.contentType ?? "no content type"}\`, which is not an image \u2014 a catch-all route serving the app shell looks exactly like this`;
2913
+ return issue({
2914
+ pageUrl: probe.url,
2915
+ message: `No \`/favicon.ico\` at the root: ${detail}. Clients that ask for it blind \u2014 feed readers, link unfurlers, older crawlers \u2014 get nothing.`,
2916
+ origin: { kind: "computed" },
2917
+ fix: {
2918
+ title: "Generate it from the icon you already have",
2919
+ snippet: [
2920
+ "// No framework convention emits an .ico: Next's icon.tsx goes through",
2921
+ "// ImageResponse, which is PNG. The container is a header plus one",
2922
+ "// entry per size plus the PNGs concatenated \u2014 thirty lines and no",
2923
+ "// dependency, over an image library you almost certainly already have.",
2924
+ "//",
2925
+ "// Fingerprint the *inputs* (the source image, the sizes), not the",
2926
+ "// bytes you produce: encoders are not stable across versions, so a",
2927
+ "// script keyed on its own output dirties the file on every commit.",
2928
+ "// Then a --check mode can verify it in CI without writing anything."
2929
+ ].join("\n"),
2930
+ language: "ts"
2931
+ }
2932
+ });
2933
+ }
2934
+ };
2935
+ var ROBOTS_BYTE_LIMIT = 500 * 1024;
2936
+ var robotstxtUnreachable = {
2937
+ id: "robotstxt.unreachable",
2938
+ severity: "error",
2939
+ summary: "A robots.txt that errors is read as forbidding the whole site",
2940
+ rigor: "spec-required",
2941
+ sources: ["ietf-rfc9309"],
2942
+ // A 404 is not a failure: §2.3.1.3 says an absent file allows everything,
2943
+ // and most sites that have none mean exactly that. Only a server that
2944
+ // answered badly, or did not answer, triggers the disallow-everything rule.
2945
+ appliesTo: (site) => site.robots !== void 0 && isRobotsFailure(site.robots.status),
2946
+ check: ({ site, issue }) => {
2947
+ const probe = site.robots;
2948
+ const detail = probe.status === 0 ? "the request failed" : `the origin answered ${probe.status}`;
2949
+ return issue({
2950
+ pageUrl: probe.url,
2951
+ message: `\`robots.txt\` could not be read: ${detail}. RFC 9309 \xA72.3.1.4 tells a crawler to assume a complete disallow for as long as this lasts \u2014 an outage on this one file takes the whole site out of the index.`,
2952
+ origin: { kind: "computed" }
2953
+ });
2954
+ }
2955
+ };
2956
+ var robotstxtOversized = {
2957
+ id: "robotstxt.oversized",
2958
+ severity: "error",
2959
+ summary: "Rules past 500 KiB of robots.txt are not guaranteed to be read",
2960
+ rigor: "spec-required",
2961
+ sources: ["ietf-rfc9309"],
2962
+ appliesTo: (site) => (site.robots?.byteLength ?? 0) > ROBOTS_BYTE_LIMIT,
2963
+ check: ({ site, issue }) => {
2964
+ const probe = site.robots;
2965
+ const kib = Math.round(probe.byteLength / 1024);
2966
+ return issue({
2967
+ pageUrl: probe.url,
2968
+ message: `\`robots.txt\` is ${kib} KiB. A parser is only required to honour the first 500 KiB (RFC 9309 \xA72.4), so every rule past that point silently does not exist.`,
2969
+ origin: { kind: "computed" }
2970
+ });
2971
+ }
2972
+ };
2973
+ var robotstxtInvalidLine = {
2974
+ id: "robotstxt.invalid-line",
2975
+ severity: "warning",
2976
+ summary: "Every line of robots.txt should parse as something",
2977
+ rigor: "spec-required",
2978
+ sources: ["ietf-rfc9309"],
2979
+ appliesTo: (site) => (site.robots?.invalidLines.length ?? 0) > 0,
2980
+ check: ({ site, issue }) => {
2981
+ const probe = site.robots;
2982
+ const shown = probe.invalidLines.slice(0, 5);
2983
+ const rest = probe.invalidLines.length - shown.length;
2984
+ return issue({
2985
+ pageUrl: probe.url,
2986
+ message: `\`robots.txt\` has ${probe.invalidLines.length} line${probe.invalidLines.length === 1 ? "" : "s"} that parse as nothing: ` + shown.map((l) => `line ${l.line} (${l.reason})`).join(", ") + `${rest > 0 ? `, and ${rest} more` : ""}. A crawler drops them silently, so the rule you meant to write is simply absent.`,
2987
+ origin: { kind: "computed" }
2988
+ });
2989
+ }
2990
+ };
2991
+ var robotstxtUnknownDirective = {
2992
+ id: "robotstxt.unknown-directive",
2993
+ severity: "info",
2994
+ summary: "Non-standard robots.txt directives are read by some crawlers and ignored by others",
2995
+ rigor: "guideline",
2996
+ sources: ["ietf-rfc9309", "google-robots-intro"],
2997
+ appliesTo: (site) => (site.robots?.unknownDirectives.length ?? 0) > 0,
2998
+ check: ({ site, issue }) => {
2999
+ const probe = site.robots;
3000
+ const names = [...new Set(probe.unknownDirectives.map((d) => d.name))];
3001
+ return issue({
3002
+ pageUrl: probe.url,
3003
+ message: `\`robots.txt\` uses ${names.map((n) => `\`${n}\``).join(", ")}, which RFC 9309 does not define. Some crawlers honour ${names.length === 1 ? "it" : "them"} and Google ignores ${names.length === 1 ? "it" : "them"} \u2014 so this is worth knowing, not necessarily worth changing.`,
3004
+ origin: { kind: "computed" }
3005
+ });
3006
+ }
3007
+ };
3008
+ var robotstxtCrossOrigin = {
3009
+ id: "robotstxt.cross-origin",
3010
+ severity: "warning",
3011
+ summary: "`/robots.txt` should not redirect to another origin",
3012
+ rigor: "vendor-spec",
3013
+ sources: ["ietf-rfc9309"],
3014
+ appliesTo: (site) => site.robots?.redirects.crossOrigin === true,
3015
+ check: ({ site, issue }) => {
3016
+ const probe = site.robots;
3017
+ return issue({
3018
+ pageUrl: probe.url,
3019
+ message: `\`robots.txt\` redirects to \`${probe.redirects.finalUrl}\`, on another origin. RFC 9309 \xA72.3.1.2 permits following it, so this works \u2014 but the policy for this site now lives somewhere this site does not control, and it is usually a proxy accident rather than a decision.`,
3020
+ origin: { kind: "computed" }
3021
+ });
3022
+ }
3023
+ };
3024
+ var robotstxtSitemapRelative = {
3025
+ id: "robotstxt.sitemap.relative",
3026
+ severity: "error",
3027
+ summary: "A `Sitemap:` declaration must be an absolute URL",
3028
+ rigor: "spec-required",
3029
+ sources: ["sitemaps-protocol", "ietf-rfc9309"],
3030
+ appliesTo: (site) => (site.robots?.sitemaps ?? []).some((s) => !isAbsolute(s.value)),
3031
+ check: ({ site, issue }) => {
3032
+ const probe = site.robots;
3033
+ const relative = probe.sitemaps.filter((s) => !isAbsolute(s.value));
3034
+ return issue({
3035
+ pageUrl: probe.url,
3036
+ message: `\`Sitemap:\` must be a full URL: ${relative.map((s) => `line ${s.line} declares \`${s.value}\``).join(", ")}. robots.txt is fetched on its own, so there is no page for a consumer to resolve a relative path against.`,
3037
+ origin: { kind: "computed" }
3038
+ });
3039
+ }
3040
+ };
3041
+ var robotsBlocksPage = {
3042
+ id: "robots.blocks-page",
3043
+ severity: "error",
3044
+ summary: "A page asking to be indexed must not be disallowed by robots.txt",
3045
+ rigor: "vendor-spec",
3046
+ sources: ["ietf-rfc9309", "google-robots-intro"],
3047
+ appliesTo: (site) => site.robots?.found === true && robotsAllows(site.robots.groups, "/").allowed,
3048
+ check: ({ site, issue }) => {
3049
+ const probe = site.robots;
3050
+ return site.pages.flatMap((page) => {
3051
+ if (!metaRobotsTokens(page).has("index")) return [];
3052
+ let path;
3053
+ try {
3054
+ const url = new URL(page.fetch.finalUrl);
3055
+ path = `${url.pathname}${url.search}`;
3056
+ } catch {
3057
+ return [];
3058
+ }
3059
+ const decision = robotsAllows(probe.groups, path);
3060
+ if (decision.allowed || !decision.rule) return [];
3061
+ return issue({
3062
+ pageUrl: page.fetch.finalUrl,
3063
+ message: `Page declares \`<meta name="robots" content="index">\` but \`robots.txt\` line ${decision.rule.line} disallows \`${decision.rule.pattern}\` for \`${decision.group}\`. robots.txt wins: the page is never fetched, so the tag asking for it is never read.`,
3064
+ origin: { kind: "computed" }
3065
+ });
3066
+ });
3067
+ }
3068
+ };
3069
+ var CHANGEFREQ = /* @__PURE__ */ new Set(["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
3070
+ var SAMPLE = 5;
3071
+ function sample(locs) {
3072
+ const shown = locs.slice(0, SAMPLE);
3073
+ const rest = locs.length - shown.length;
3074
+ return `${shown.map((l) => `\`${l}\``).join(", ")}${rest > 0 ? `, and ${rest} more` : ""}`;
3075
+ }
3076
+ var sitemapMissing = {
3077
+ id: "sitemap.missing",
3078
+ severity: "warning",
3079
+ summary: "A site should publish a sitemap",
3080
+ rigor: "guideline",
3081
+ sources: ["sitemaps-protocol", "google-sitemaps"],
3082
+ // A warning rather than an error, and the plan settles it: a small, fully
3083
+ // linked site genuinely may not need one. Google's own guidance says
3084
+ // "usually worth having", which is exactly a guideline.
3085
+ appliesTo: (site) => site.discovery !== void 0 && !site.discovery.diagnostics.found,
3086
+ check: ({ site, issue }) => issue({
3087
+ pageUrl: `${site.origin}/sitemap.xml`,
3088
+ message: "No sitemap was found \u2014 not declared in `robots.txt`, and not at a well-known path. Discovery then depends entirely on what links to what, which is the part of a site nobody audits.",
3089
+ origin: { kind: "computed" }
3090
+ })
3091
+ };
3092
+ var sitemapUnparsable = {
3093
+ id: "sitemap.unparsable",
3094
+ severity: "error",
3095
+ summary: "A located sitemap must be a well-formed `<urlset>` or `<sitemapindex>`",
3096
+ rigor: "spec-required",
3097
+ sources: ["sitemaps-protocol"],
3098
+ appliesTo: (site) => site.discovery?.diagnostics.found === true && !site.discovery.diagnostics.wellFormed,
3099
+ check: ({ site, issue }) => {
3100
+ const diagnostics = site.discovery.diagnostics;
3101
+ return issue({
3102
+ pageUrl: diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3103
+ message: "A sitemap was served but does not parse as XML. The usual cause is an HTML error page answered with a 200 \u2014 which reads as a healthy sitemap to anything that only checks the status.",
3104
+ origin: { kind: "computed" }
3105
+ });
3106
+ }
3107
+ };
3108
+ var sitemapEmpty = {
3109
+ id: "sitemap.empty",
3110
+ severity: "warning",
3111
+ summary: "A sitemap that parses should list something",
3112
+ rigor: "guideline",
3113
+ sources: ["sitemaps-protocol"],
3114
+ // Only when the crawl found pages: an empty sitemap on a site with no pages
3115
+ // is consistent, and saying otherwise would be noise.
3116
+ appliesTo: (site) => site.discovery?.diagnostics.wellFormed === true && site.discovery.urls.length === 0 && site.pages.length > 0,
3117
+ check: ({ site, issue }) => {
3118
+ const diagnostics = site.discovery.diagnostics;
3119
+ return issue({
3120
+ pageUrl: diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3121
+ message: `The sitemap parses and lists no URLs, while the crawl found ${site.pages.length}. goflag falls back to crawling \u2014 a consumer that trusts the sitemap has nothing to read.`,
3122
+ origin: { kind: "computed" }
3123
+ });
3124
+ }
3125
+ };
3126
+ var sitemapIndexChildError = {
3127
+ id: "sitemap.index.child-error",
3128
+ severity: "error",
3129
+ summary: "Every child of a sitemap index must be reachable and parseable",
3130
+ rigor: "spec-required",
3131
+ sources: ["sitemaps-protocol"],
3132
+ appliesTo: (site) => (site.discovery?.diagnostics.childSitemapErrors ?? 0) > 0,
3133
+ check: ({ site, issue }) => {
3134
+ const diagnostics = site.discovery.diagnostics;
3135
+ const total = diagnostics.childSitemapCount;
3136
+ return issue({
3137
+ pageUrl: diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3138
+ message: `${diagnostics.childSitemapErrors} of ${total} child sitemaps could not be read. The index declares an inventory and part of it is missing, so whatever those documents listed is invisible \u2014 and nothing says how much that is.`,
3139
+ origin: { kind: "computed" }
3140
+ });
3141
+ }
3142
+ };
3143
+ var sitemapEntryInvalidUrl = {
3144
+ id: "sitemap.entry.invalid-url",
3145
+ severity: "error",
3146
+ summary: "Every `<loc>` must be an absolute, parseable URL",
3147
+ rigor: "spec-required",
3148
+ sources: ["sitemaps-protocol"],
3149
+ appliesTo: (site) => (site.discovery?.urls ?? []).some((entry) => !isAbsolute(entry.loc)),
3150
+ check: ({ site, issue }) => {
3151
+ const bad = site.discovery.urls.filter((entry) => !isAbsolute(entry.loc));
3152
+ return issue({
3153
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3154
+ message: `${bad.length} \`<loc>\` value${bad.length === 1 ? " is" : "s are"} not an absolute URL: ${sample(bad.map((e) => e.loc))}. A sitemap is fetched on its own, so a consumer has nothing to resolve them against.`,
3155
+ origin: { kind: "computed" }
3156
+ });
3157
+ }
3158
+ };
3159
+ var sitemapEntryCrossHost = {
3160
+ id: "sitemap.entry.cross-host",
3161
+ severity: "error",
3162
+ summary: "A sitemap should only list URLs on its own host",
3163
+ rigor: "spec-required",
3164
+ sources: ["sitemaps-protocol"],
3165
+ // `www` and the apex are different hosts to a consumer, which is the case
3166
+ // this actually catches: a sitemap generated against one and served on the
3167
+ // other. The cross-submission escape hatch needs the other host's robots.txt
3168
+ // and arrives with the cross-artefact rules.
3169
+ appliesTo: (site) => crossHostEntries(site).length > 0,
3170
+ check: ({ site, issue }) => {
3171
+ const bad = crossHostEntries(site);
3172
+ return issue({
3173
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3174
+ message: `${bad.length} entr${bad.length === 1 ? "y names a host" : "ies name hosts"} other than this sitemap's: ${sample(bad)}. A consumer may drop them \u2014 the sitemap only speaks for the host that serves it.`,
3175
+ origin: { kind: "computed" }
3176
+ });
3177
+ }
3178
+ };
3179
+ var sitemapEntryProtocolMismatch = {
3180
+ id: "sitemap.entry.protocol-mismatch",
3181
+ severity: "warning",
3182
+ summary: "A sitemap should not mix `http` and `https` entries",
3183
+ rigor: "spec-required",
3184
+ sources: ["sitemaps-protocol"],
3185
+ appliesTo: (site) => protocolsIn(site).size > 1,
3186
+ check: ({ site, issue }) => {
3187
+ const protocols = [...protocolsIn(site)].sort();
3188
+ return issue({
3189
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3190
+ message: `The sitemap lists both ${protocols.join(" and ")} URLs. One of the two sets names pages the site does not serve at those addresses, and a consumer has no way to tell which.`,
3191
+ origin: { kind: "computed" }
3192
+ });
3193
+ }
3194
+ };
3195
+ var sitemapLastmodInvalid = {
3196
+ id: "sitemap.lastmod.invalid",
3197
+ severity: "warning",
3198
+ summary: "`<lastmod>` must be a W3C Datetime, and must not be in the future",
3199
+ rigor: "spec-required",
3200
+ sources: ["sitemaps-protocol", "google-sitemaps"],
3201
+ appliesTo: (site) => badLastmods(site).length > 0,
3202
+ check: ({ site, issue }) => {
3203
+ const bad = badLastmods(site);
3204
+ const malformed = bad.filter((e) => e.reason === "malformed");
3205
+ const future = bad.filter((e) => e.reason === "future");
3206
+ const parts = [];
3207
+ if (malformed.length > 0) {
3208
+ parts.push(
3209
+ `${malformed.length} not a W3C Datetime (${sample(malformed.map((e) => e.value))})`
3210
+ );
3211
+ }
3212
+ if (future.length > 0) {
3213
+ parts.push(`${future.length} dated in the future (${sample(future.map((e) => e.value))})`);
3214
+ }
3215
+ return issue({
3216
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3217
+ message: `\`<lastmod>\` values a consumer cannot use: ${parts.join("; ")}. Google ignores the field entirely when it stops trusting it, so one bad batch costs the whole site the signal.`,
3218
+ origin: { kind: "computed" }
3219
+ });
3220
+ }
3221
+ };
3222
+ var sitemapFieldInvalid = {
3223
+ id: "sitemap.field.invalid",
3224
+ severity: "warning",
3225
+ summary: "`<changefreq>` and `<priority>` must hold the values the protocol defines",
3226
+ rigor: "spec-required",
3227
+ sources: ["sitemaps-protocol", "google-sitemaps"],
3228
+ appliesTo: (site) => badFields(site).length > 0,
3229
+ check: ({ site, issue }) => {
3230
+ const bad = badFields(site);
3231
+ return issue({
3232
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3233
+ message: `${bad.length} entr${bad.length === 1 ? "y carries a field" : "ies carry fields"} outside the protocol's values: ${sample(bad)}. Google ignores both fields either way \u2014 so this is worth fixing or deleting, never worth trusting.`,
3234
+ origin: { kind: "computed" }
3235
+ });
3236
+ }
3237
+ };
3238
+ function sitemapHost(site) {
3239
+ const url = site.discovery?.diagnostics.sitemapUrl;
3240
+ try {
3241
+ return url ? new URL(url).host : new URL(site.origin).host;
3242
+ } catch {
3243
+ return void 0;
3244
+ }
3245
+ }
3246
+ function crossHostEntries(site) {
3247
+ const host = sitemapHost(site);
3248
+ if (!host) return [];
3249
+ return (site.discovery?.urls ?? []).filter((entry) => {
3250
+ if (!isAbsolute(entry.loc)) return false;
3251
+ try {
3252
+ return new URL(entry.loc).host !== host;
3253
+ } catch {
3254
+ return false;
3255
+ }
3256
+ }).map((entry) => entry.loc);
3257
+ }
3258
+ function protocolsIn(site) {
3259
+ const protocols = /* @__PURE__ */ new Set();
3260
+ for (const entry of site.discovery?.urls ?? []) {
3261
+ try {
3262
+ protocols.add(new URL(entry.loc).protocol.replace(":", ""));
3263
+ } catch {
3264
+ continue;
3265
+ }
3266
+ }
3267
+ return protocols;
3268
+ }
3269
+ var W3C_DATETIME = /^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$/;
3270
+ function badLastmods(site) {
3271
+ const out = [];
3272
+ const tomorrow = Date.now() + 24 * 60 * 60 * 1e3;
3273
+ for (const entry of site.discovery?.urls ?? []) {
3274
+ const value = entry.lastmod?.trim();
3275
+ if (!value) continue;
3276
+ if (!W3C_DATETIME.test(value)) {
3277
+ out.push({ value, reason: "malformed" });
3278
+ continue;
3279
+ }
3280
+ const parsed = Date.parse(value);
3281
+ if (Number.isFinite(parsed) && parsed > tomorrow) out.push({ value, reason: "future" });
3282
+ }
3283
+ return out;
3284
+ }
3285
+ function badFields(site) {
3286
+ const out = [];
3287
+ for (const entry of site.discovery?.urls ?? []) {
3288
+ const changefreq = entry.changefreq?.trim().toLowerCase();
3289
+ if (changefreq && !CHANGEFREQ.has(changefreq)) {
3290
+ out.push(`${entry.loc} \u2014 changefreq \`${entry.changefreq}\``);
3291
+ continue;
3292
+ }
3293
+ const priority = entry.priority?.trim();
3294
+ if (priority === void 0) continue;
3295
+ const value = Number(priority);
3296
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
3297
+ out.push(`${entry.loc} \u2014 priority \`${entry.priority}\``);
3298
+ }
3299
+ }
3300
+ return out;
3301
+ }
3302
+ function sameUrlKey(url) {
3303
+ try {
3304
+ const parsed = new URL(url);
3305
+ parsed.hash = "";
3306
+ const path = parsed.pathname.replace(/\/+$/, "");
3307
+ return `${parsed.origin}${path || "/"}${parsed.search}`;
3308
+ } catch {
3309
+ return void 0;
3310
+ }
3311
+ }
3312
+ function pagesByUrl(site) {
3313
+ const byUrl = /* @__PURE__ */ new Map();
3314
+ for (const page of site.pages) {
3315
+ const key = sameUrlKey(page.fetch.finalUrl);
3316
+ if (key) byUrl.set(key, page);
3317
+ }
3318
+ return byUrl;
3319
+ }
3320
+ function saysNoindex(page) {
3321
+ if (metaRobotsTokens(page).has("noindex")) return true;
3322
+ const header = page.fetch.headers["x-robots-tag"] ?? "";
3323
+ return header.split(",").map((token) => token.trim().toLowerCase()).includes("noindex");
3324
+ }
3325
+ var sitemapEntryBlockedByRobots = {
3326
+ id: "sitemap.entry.blocked-by-robots",
3327
+ severity: "error",
3328
+ summary: "A sitemap must not list URLs that robots.txt forbids fetching",
3329
+ rigor: "vendor-spec",
3330
+ sources: ["ietf-rfc9309", "sitemaps-protocol"],
3331
+ // Skipped when the whole origin is disallowed: `robots.blocks-site` is that
3332
+ // finding, and repeating it once per sitemap entry would bury it.
3333
+ appliesTo: (site) => site.robots?.found === true && robotsAllows(site.robots.groups, "/").allowed && (site.discovery?.urls.length ?? 0) > 0,
3334
+ check: ({ site, issue }) => {
3335
+ const probe = site.robots;
3336
+ const blocked = [];
3337
+ for (const entry of site.discovery.urls) {
3338
+ let path;
3339
+ try {
3340
+ const url = new URL(entry.loc);
3341
+ path = `${url.pathname}${url.search}`;
3342
+ } catch {
3343
+ continue;
3344
+ }
3345
+ if (!robotsAllows(probe.groups, path).allowed) blocked.push(entry.loc);
3346
+ }
3347
+ if (blocked.length === 0) return [];
3348
+ return issue({
3349
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3350
+ message: `${blocked.length} sitemap entr${blocked.length === 1 ? "y is" : "ies are"} disallowed by \`robots.txt\`: ${sample(blocked)}. The sitemap says "index this" and robots.txt says "never fetch it" \u2014 both cannot hold, and robots.txt is the one that decides.`,
3351
+ origin: { kind: "computed" }
3352
+ });
3353
+ }
3354
+ };
3355
+ var sitemapEntryNoindex = {
3356
+ id: "sitemap.entry.noindex",
3357
+ severity: "warning",
3358
+ summary: "A sitemap must not list URLs that ask not to be indexed",
3359
+ rigor: "vendor-spec",
3360
+ sources: ["sitemaps-protocol", "google-robots-meta"],
3361
+ // Only the crawled pages can be judged: a sitemap entry goflag never fetched
3362
+ // has no `noindex` to have seen, and assuming one either way would invent a
3363
+ // finding or hide one.
3364
+ appliesTo: (site) => (site.discovery?.urls.length ?? 0) > 0,
3365
+ check: ({ site, issue }) => {
3366
+ const byUrl = pagesByUrl(site);
3367
+ const conflicting = [];
3368
+ for (const entry of site.discovery.urls) {
3369
+ const key = sameUrlKey(entry.loc);
3370
+ const page = key ? byUrl.get(key) : void 0;
3371
+ if (page && saysNoindex(page)) conflicting.push(entry.loc);
3372
+ }
3373
+ if (conflicting.length === 0) return [];
3374
+ return issue({
3375
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3376
+ message: `${conflicting.length} sitemap entr${conflicting.length === 1 ? "y declares" : "ies declare"} \`noindex\`: ${sample(conflicting)}. "Please index this" and "do not index this" are the same site's two answers to one question.`,
3377
+ origin: { kind: "computed" }
3378
+ });
3379
+ }
3380
+ };
3381
+ var sitemapEntryNonCanonical = {
3382
+ id: "sitemap.entry.non-canonical",
3383
+ severity: "warning",
3384
+ summary: "A sitemap should list canonical URLs",
3385
+ rigor: "vendor-spec",
3386
+ sources: ["sitemaps-protocol", "google-canonicalization"],
3387
+ appliesTo: (site) => (site.discovery?.urls.length ?? 0) > 0,
3388
+ check: ({ site, issue }) => {
3389
+ const byUrl = pagesByUrl(site);
3390
+ const wrong = [];
3391
+ for (const entry of site.discovery.urls) {
3392
+ const key = sameUrlKey(entry.loc);
3393
+ const page = key ? byUrl.get(key) : void 0;
3394
+ const canonical = page?.links.canonical;
3395
+ if (!page || !canonical) continue;
3396
+ const canonicalKey = sameUrlKey(canonical);
3397
+ if (canonicalKey && canonicalKey !== key) {
3398
+ wrong.push(`${entry.loc} \u2192 ${canonical}`);
3399
+ }
3400
+ }
3401
+ if (wrong.length === 0) return [];
3402
+ return issue({
3403
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3404
+ message: `${wrong.length} sitemap entr${wrong.length === 1 ? "y names a page whose canonical" : "ies name pages whose canonicals"} point elsewhere: ${sample(wrong)}. The sitemap is a list of what to index, so it should name the URL the site itself prefers.`,
3405
+ origin: { kind: "computed" }
3406
+ });
3407
+ }
3408
+ };
3409
+ var sitemapOrphans = {
3410
+ id: "sitemap.orphans",
3411
+ severity: "warning",
3412
+ summary: "Indexable pages the crawl found should be listed in the sitemap",
3413
+ rigor: "guideline",
3414
+ sources: ["sitemaps-protocol", "google-sitemaps"],
3415
+ // One finding with a count and a sample, not one per page — the same shape
3416
+ // translation holes take, and for the same reason: forty repeats of one
3417
+ // omission is noise, and the omission is a property of the sitemap.
3418
+ appliesTo: (site) => site.discovery?.diagnostics.found === true && site.discovery.urls.length > 0,
3419
+ check: ({ site, issue }) => {
3420
+ const listed = new Set(
3421
+ site.discovery.urls.map((entry) => sameUrlKey(entry.loc)).filter(Boolean)
3422
+ );
3423
+ const orphans = site.pages.filter((page) => !saysNoindex(page)).map((page) => ({ page, key: sameUrlKey(page.fetch.finalUrl) })).filter(({ key }) => key !== void 0 && !listed.has(key)).map(({ page }) => page.fetch.finalUrl);
3424
+ if (orphans.length === 0) return [];
3425
+ return issue({
3426
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3427
+ message: `${orphans.length} crawled page${orphans.length === 1 ? "" : "s"} ask${orphans.length === 1 ? "s" : ""} to be indexed and ${orphans.length === 1 ? "is" : "are"} absent from the sitemap: ${sample(orphans)}. A consumer that reads the sitemap rather than following links will never see ${orphans.length === 1 ? "it" : "them"}.`,
3428
+ origin: { kind: "computed" }
3429
+ });
3430
+ }
3431
+ };
3432
+ function entryProbes(site) {
3433
+ return [...site.sitemapEntries?.byUrl.values() ?? []];
3434
+ }
3435
+ function coverageNote(site) {
3436
+ const unprobed = site.sitemapEntries?.unprobed ?? 0;
3437
+ if (unprobed === 0) return "";
3438
+ return ` ${unprobed} further entr${unprobed === 1 ? "y was" : "ies were"} not checked \u2014 this count is a floor, not a total.`;
3439
+ }
3440
+ var sitemapEntryUnreachable = {
3441
+ id: "sitemap.entry.unreachable",
3442
+ severity: "error",
3443
+ summary: "Every URL a sitemap lists must answer",
3444
+ rigor: "vendor-spec",
3445
+ sources: ["sitemaps-protocol", "google-sitemaps"],
3446
+ appliesTo: (site) => entryProbes(site).some((probe) => isDead(probe.status)),
3447
+ check: ({ site, issue }) => {
3448
+ const dead = entryProbes(site).filter((probe) => isDead(probe.status));
3449
+ return issue({
3450
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3451
+ message: `${dead.length} sitemap entr${dead.length === 1 ? "y does" : "ies do"} not answer: ${sample(dead.map((probe) => `${probe.url} (${probe.status === 0 ? "no response" : `HTTP ${probe.status}`})`))}. A sitemap is a list of pages to index, so every dead entry spends crawl budget on a promise the site does not keep.${coverageNote(site)}`,
3452
+ origin: { kind: "computed" }
3453
+ });
3454
+ }
3455
+ };
3456
+ var sitemapEntryRedirects = {
3457
+ id: "sitemap.entry.redirects",
3458
+ severity: "warning",
3459
+ summary: "A sitemap should list final URLs, not URLs that redirect",
3460
+ rigor: "guideline",
3461
+ sources: ["sitemaps-protocol", "google-sitemaps"],
3462
+ appliesTo: (site) => entryProbes(site).some((probe) => probe.redirected && !isDead(probe.status)),
3463
+ check: ({ site, issue }) => {
3464
+ const moved = entryProbes(site).filter((probe) => probe.redirected && !isDead(probe.status));
3465
+ return issue({
3466
+ pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
3467
+ message: `${moved.length} sitemap entr${moved.length === 1 ? "y redirects" : "ies redirect"}: ${sample(moved.map((probe) => `${probe.url} \u2192 ${probe.finalUrl}`))}. Google asks for the final URL, and every hop is crawl budget spent plus one more chance for a consumer to disagree about which address is the page.${coverageNote(site)}`,
3468
+ origin: { kind: "computed" }
3469
+ });
3470
+ }
3471
+ };
3472
+ function isDead(status) {
3473
+ return status === 0 || status >= 400;
3474
+ }
3475
+ function isRobotsFailure(status) {
3476
+ return status === 0 || status >= 500;
3477
+ }
3478
+ function isAbsolute(value) {
3479
+ try {
3480
+ const url = new URL(value);
3481
+ return url.protocol === "http:" || url.protocol === "https:";
3482
+ } catch {
3483
+ return false;
3484
+ }
3485
+ }
2188
3486
  var SITE_RULES = [
2189
3487
  hreflangMissing,
2190
3488
  hreflangSitemapMismatch,
2191
- robotsBlocksSite
3489
+ iconsIcoMissing,
3490
+ robotsBlocksPage,
3491
+ robotsBlocksSite,
3492
+ robotstxtCrossOrigin,
3493
+ robotstxtInvalidLine,
3494
+ robotstxtOversized,
3495
+ robotstxtSitemapRelative,
3496
+ robotstxtUnknownDirective,
3497
+ robotstxtUnreachable,
3498
+ sitemapEmpty,
3499
+ sitemapEntryBlockedByRobots,
3500
+ sitemapEntryCrossHost,
3501
+ sitemapEntryInvalidUrl,
3502
+ sitemapEntryNoindex,
3503
+ sitemapEntryNonCanonical,
3504
+ sitemapEntryProtocolMismatch,
3505
+ sitemapEntryRedirects,
3506
+ sitemapEntryUnreachable,
3507
+ sitemapFieldInvalid,
3508
+ sitemapIndexChildError,
3509
+ sitemapLastmodInvalid,
3510
+ sitemapMissing,
3511
+ sitemapOrphans,
3512
+ sitemapUnparsable
2192
3513
  ];
2193
3514
  function getSiteRule(id) {
2194
3515
  return SITE_RULES.find((rule) => rule.id === id);
@@ -2210,7 +3531,12 @@ function lintSite(site, rules = SITE_RULES) {
2210
3531
  origin: input.origin,
2211
3532
  fix: input.fix,
2212
3533
  suggestion: input.suggestion,
2213
- pageUrl: input.pageUrl
3534
+ pageUrl: input.pageUrl,
3535
+ // Stamped from the descriptor, never from the check: a rule does not
3536
+ // get to claim a rigor per finding. Absent on the three rules that
3537
+ // predate the field, which is how the gap stays visible instead of
3538
+ // being filled with a plausible citation.
3539
+ ...rule.rigor ? { rigor: rule.rigor, sources: rule.sources ?? [] } : {}
2214
3540
  })
2215
3541
  });
2216
3542
  } catch (err) {
@@ -2413,14 +3739,15 @@ async function discoverSitemap(baseUrl, options = {}) {
2413
3739
  };
2414
3740
  const robots = await probeRobots(origin, { signal: options.signal });
2415
3741
  diagnostics.robotsFound = robots.found;
3742
+ const declaredUrls = robots.sitemaps.map((entry) => entry.value);
2416
3743
  const candidates = [];
2417
- for (const declared of robots.sitemaps) {
3744
+ for (const declared of declaredUrls) {
2418
3745
  candidates.push({ url: declared, source: "robots", declaredInRobots: true });
2419
3746
  }
2420
3747
  candidates.push({
2421
3748
  url: new URL("/sitemap.xml", origin).toString(),
2422
3749
  source: "well-known",
2423
- declaredInRobots: robots.sitemaps.includes(new URL("/sitemap.xml", origin).toString())
3750
+ declaredInRobots: declaredUrls.includes(new URL("/sitemap.xml", origin).toString())
2424
3751
  });
2425
3752
  candidates.push({
2426
3753
  url: new URL("/sitemap_index.xml", origin).toString(),
@@ -2593,9 +3920,396 @@ function relaxTlsIfRequested2(enabled) {
2593
3920
  };
2594
3921
  }
2595
3922
 
2596
- // src/lib/core/clusters.ts
2597
- function normalise(raw) {
2598
- let url;
3923
+ // src/lib/core/net/fetch-url.ts
3924
+ var DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
3925
+ var DEFAULT_TIMEOUT_MS = 8e3;
3926
+ var DEFAULT_MAX_BYTES = 3 * 1024 * 1024;
3927
+ var DEFAULT_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
3928
+ var TEXTY = /(text\/|application\/(xhtml\+xml|xml|json|.*\+xml|.*\+json|javascript))/;
3929
+ async function fetchUrl(url, options = {}) {
3930
+ const method = options.method ?? "GET";
3931
+ const redirect = options.redirect ?? "follow";
3932
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
3933
+ const startedAt = Date.now();
3934
+ const { signal, cleanup } = combineSignals(
3935
+ options.signal,
3936
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS
3937
+ );
3938
+ const restoreTls = relaxTlsIfRequested3(options.allowInsecureTls);
3939
+ try {
3940
+ const res = await fetch(url, {
3941
+ method,
3942
+ signal,
3943
+ redirect,
3944
+ headers: {
3945
+ "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
3946
+ accept: options.accept ?? DEFAULT_ACCEPT
3947
+ }
3948
+ });
3949
+ const contentType = normaliseContentType(res.headers.get("content-type"));
3950
+ const result = {
3951
+ requestedUrl: url,
3952
+ finalUrl: res.url || url,
3953
+ status: res.status,
3954
+ redirected: res.redirected,
3955
+ redirectChain: [],
3956
+ contentType,
3957
+ durationMs: Date.now() - startedAt
3958
+ };
3959
+ const retryAfter = res.headers.get("retry-after");
3960
+ if (retryAfter) result.retryAfter = retryAfter;
3961
+ if (redirect === "manual" && isRedirectStatus(res.status)) {
3962
+ const location = res.headers.get("location");
3963
+ if (location) {
3964
+ try {
3965
+ const next = new URL(location, url).toString();
3966
+ result.finalUrl = next;
3967
+ result.redirectChain = [next];
3968
+ result.redirected = true;
3969
+ } catch {
3970
+ }
3971
+ }
3972
+ await res.body?.cancel().catch(() => void 0);
3973
+ return result;
3974
+ }
3975
+ if (method === "HEAD") {
3976
+ await res.body?.cancel().catch(() => void 0);
3977
+ return result;
3978
+ }
3979
+ if (contentType && !TEXTY.test(contentType)) {
3980
+ await res.body?.cancel().catch(() => void 0);
3981
+ return result;
3982
+ }
3983
+ const { text, truncated } = await readCappedText(res, maxBytes);
3984
+ result.body = text;
3985
+ if (truncated) result.truncated = true;
3986
+ return result;
3987
+ } catch (err) {
3988
+ return {
3989
+ requestedUrl: url,
3990
+ finalUrl: url,
3991
+ status: 0,
3992
+ redirected: false,
3993
+ redirectChain: [],
3994
+ reason: classifyError(err, options.signal),
3995
+ durationMs: Date.now() - startedAt
3996
+ };
3997
+ } finally {
3998
+ cleanup();
3999
+ restoreTls();
4000
+ }
4001
+ }
4002
+ function isRedirectStatus(status) {
4003
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
4004
+ }
4005
+ function normaliseContentType(raw) {
4006
+ if (!raw) return void 0;
4007
+ const semi = raw.indexOf(";");
4008
+ return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase() || void 0;
4009
+ }
4010
+ async function readCappedText(res, maxBytes) {
4011
+ const reader = res.body?.getReader();
4012
+ if (!reader) {
4013
+ const text = await res.text();
4014
+ if (text.length > maxBytes) {
4015
+ return { text: text.slice(0, maxBytes), truncated: true };
4016
+ }
4017
+ return { text, truncated: false };
4018
+ }
4019
+ const chunks = [];
4020
+ let received = 0;
4021
+ let truncated = false;
4022
+ for (; ; ) {
4023
+ const { done, value } = await reader.read();
4024
+ if (done) break;
4025
+ if (!value) continue;
4026
+ chunks.push(value);
4027
+ received += value.byteLength;
4028
+ if (received >= maxBytes) {
4029
+ truncated = true;
4030
+ await reader.cancel().catch(() => void 0);
4031
+ break;
4032
+ }
4033
+ }
4034
+ const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
4035
+ const sliced = buf.byteLength > maxBytes ? buf.subarray(0, maxBytes) : buf;
4036
+ return { text: sliced.toString("utf8"), truncated };
4037
+ }
4038
+ function classifyError(err, callerSignal) {
4039
+ if (isAbortError(err)) {
4040
+ return callerSignal?.aborted ? "abort" : "timeout";
4041
+ }
4042
+ const code = errorCode(err);
4043
+ if (code) {
4044
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "dns";
4045
+ if (code.startsWith("CERT_") || code.includes("SELF_SIGNED") || code.includes("CERT")) {
4046
+ return "tls";
4047
+ }
4048
+ }
4049
+ const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
4050
+ if (message.includes("certificate") || message.includes("tls") || message.includes("ssl")) {
4051
+ return "tls";
4052
+ }
4053
+ return "network";
4054
+ }
4055
+ function isAbortError(err) {
4056
+ return err instanceof Error && err.name === "AbortError";
4057
+ }
4058
+ function errorCode(err) {
4059
+ if (err && typeof err === "object") {
4060
+ const direct = err.code;
4061
+ if (typeof direct === "string") return direct;
4062
+ const cause = err.cause;
4063
+ if (cause && typeof cause === "object") {
4064
+ const causeCode = cause.code;
4065
+ if (typeof causeCode === "string") return causeCode;
4066
+ }
4067
+ }
4068
+ return void 0;
4069
+ }
4070
+ function relaxTlsIfRequested3(enabled) {
4071
+ if (!enabled) return () => void 0;
4072
+ const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
4073
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
4074
+ return () => {
4075
+ if (previous === void 0) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
4076
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous;
4077
+ };
4078
+ }
4079
+
4080
+ // src/lib/core/links/classify.ts
4081
+ function classifyLink(input) {
4082
+ if (input.status === 0) return "broken";
4083
+ if (input.loop) return "broken";
4084
+ if (input.softNotFound) return "warning";
4085
+ if (input.status === 403 || input.status === 429) return "blocked";
4086
+ if (input.status >= 400) return "broken";
4087
+ if (input.status >= 200 && input.status < 300) {
4088
+ return input.redirected ? "redirect" : "ok";
4089
+ }
4090
+ return "broken";
4091
+ }
4092
+
4093
+ // src/lib/core/links/check.ts
4094
+ var DEFAULTS2 = {
4095
+ timeoutMs: 8e3,
4096
+ maxRedirects: 10,
4097
+ retries: 1,
4098
+ backoffBaseMs: 300,
4099
+ maxBackoffMs: 3e3,
4100
+ softBodyBytes: 16 * 1024
4101
+ };
4102
+ var NOT_FOUND_RE = /\b(not\s+found|page\s+doesn'?t\s+exist|404|no\s+such\s+page)\b/i;
4103
+ var SOFT_404_BODY_LIMIT = 2048;
4104
+ async function checkLink(url, options = {}) {
4105
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
4106
+ const started = Date.now();
4107
+ const scheme = schemeOf(url);
4108
+ if (scheme !== "http" && scheme !== "https") {
4109
+ return {
4110
+ url,
4111
+ finalUrl: url,
4112
+ status: 0,
4113
+ verdict: "skipped",
4114
+ method: "GET",
4115
+ redirectChain: [],
4116
+ reason: scheme ? `unsupported scheme: ${scheme}` : "not a URL",
4117
+ checkedAt,
4118
+ durationMs: Date.now() - started
4119
+ };
4120
+ }
4121
+ const outcome = await probeWithRetry(url, options);
4122
+ const softNotFound = isSoftNotFound(outcome);
4123
+ const verdict = classifyLink({
4124
+ status: outcome.status,
4125
+ redirected: outcome.redirectChain.length > 0,
4126
+ reason: outcome.reason,
4127
+ softNotFound,
4128
+ loop: outcome.loop
4129
+ });
4130
+ return {
4131
+ url,
4132
+ finalUrl: outcome.finalUrl,
4133
+ status: outcome.status,
4134
+ verdict,
4135
+ method: outcome.method,
4136
+ redirectChain: outcome.redirectChain,
4137
+ reason: describeReason(outcome, softNotFound),
4138
+ checkedAt,
4139
+ durationMs: Date.now() - started
4140
+ };
4141
+ }
4142
+ async function probeWithRetry(url, options) {
4143
+ const retries = options.retries ?? DEFAULTS2.retries;
4144
+ const sleep = options.sleep ?? defaultSleep;
4145
+ let outcome = await probeWithHeadFallback(url, options);
4146
+ for (let attempt = 0; attempt < retries; attempt++) {
4147
+ if (!isTransient(outcome)) break;
4148
+ if (options.signal?.aborted) break;
4149
+ const delay = backoffDelay(outcome, attempt, options);
4150
+ if (delay > 0) await sleep(delay);
4151
+ outcome = await probeWithHeadFallback(url, options);
4152
+ }
4153
+ return outcome;
4154
+ }
4155
+ async function probeWithHeadFallback(url, options) {
4156
+ const head = await followRedirects(url, "HEAD", options);
4157
+ if (headRejected(head) || head.status >= 200 && head.status < 300) {
4158
+ const get = await followRedirects(url, "GET", options);
4159
+ if (get.status === 0 && head.status !== 0 && !headRejected(head)) return head;
4160
+ return get;
4161
+ }
4162
+ return head;
4163
+ }
4164
+ function headRejected(o) {
4165
+ return o.status === 405 || o.status === 501 || o.status === 403 || o.status === 0;
4166
+ }
4167
+ async function followRedirects(url, method, options) {
4168
+ const maxRedirects = options.maxRedirects ?? DEFAULTS2.maxRedirects;
4169
+ const chain = [];
4170
+ const visited = /* @__PURE__ */ new Set([url]);
4171
+ let current = url;
4172
+ for (let hop = 0; hop <= maxRedirects; hop++) {
4173
+ const res = await fetchUrl(current, {
4174
+ method,
4175
+ redirect: "manual",
4176
+ timeoutMs: options.timeoutMs ?? DEFAULTS2.timeoutMs,
4177
+ allowInsecureTls: options.allowInsecureTls,
4178
+ userAgent: options.userAgent,
4179
+ maxBytes: method === "GET" ? options.softBodyBytes ?? DEFAULTS2.softBodyBytes : void 0
4180
+ });
4181
+ if (res.status === 0) {
4182
+ return {
4183
+ status: 0,
4184
+ finalUrl: current,
4185
+ redirectChain: chain,
4186
+ reason: res.reason,
4187
+ loop: false,
4188
+ method
4189
+ };
4190
+ }
4191
+ const next = res.redirectChain[0];
4192
+ if (next) {
4193
+ chain.push(next);
4194
+ if (visited.has(next)) {
4195
+ return { status: res.status, finalUrl: next, redirectChain: chain, loop: true, method };
4196
+ }
4197
+ visited.add(next);
4198
+ current = next;
4199
+ continue;
4200
+ }
4201
+ return {
4202
+ status: res.status,
4203
+ finalUrl: res.finalUrl,
4204
+ redirectChain: chain,
4205
+ loop: false,
4206
+ body: res.body,
4207
+ retryAfter: res.retryAfter,
4208
+ method
4209
+ };
4210
+ }
4211
+ return { status: 0, finalUrl: current, redirectChain: chain, loop: true, method };
4212
+ }
4213
+ function isSoftNotFound(o) {
4214
+ if (o.status < 200 || o.status >= 300) return false;
4215
+ if (o.body === void 0) return false;
4216
+ if (o.body.length > SOFT_404_BODY_LIMIT) return false;
4217
+ return NOT_FOUND_RE.test(o.body);
4218
+ }
4219
+ function isTransient(o) {
4220
+ if (o.status === 0) return true;
4221
+ if (o.status === 429) return true;
4222
+ if (o.status >= 500) return true;
4223
+ return false;
4224
+ }
4225
+ function backoffDelay(o, attempt, options) {
4226
+ const max = options.maxBackoffMs ?? DEFAULTS2.maxBackoffMs;
4227
+ const retryAfterMs = parseRetryAfter(o.retryAfter);
4228
+ if (retryAfterMs !== void 0) return Math.min(retryAfterMs, max);
4229
+ const base = options.backoffBaseMs ?? DEFAULTS2.backoffBaseMs;
4230
+ const exp = base * 2 ** attempt;
4231
+ const jitter = Math.floor(Math.random() * base);
4232
+ return Math.min(exp + jitter, max);
4233
+ }
4234
+ function parseRetryAfter(value) {
4235
+ if (!value) return void 0;
4236
+ const seconds = Number(value);
4237
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
4238
+ const date = Date.parse(value);
4239
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
4240
+ return void 0;
4241
+ }
4242
+ function describeReason(o, softNotFound) {
4243
+ if (o.reason) return o.reason;
4244
+ if (o.loop) return "redirect loop";
4245
+ if (softNotFound) return "soft-404";
4246
+ if (o.status === 429) return "429 rate-limited";
4247
+ if (o.status === 403) return "403 forbidden";
4248
+ if (o.status >= 400) return `${o.status}`;
4249
+ return void 0;
4250
+ }
4251
+ function schemeOf(url) {
4252
+ try {
4253
+ return new URL(url).protocol.replace(/:$/, "").toLowerCase();
4254
+ } catch {
4255
+ return null;
4256
+ }
4257
+ }
4258
+ function defaultSleep(ms) {
4259
+ return new Promise((resolve) => setTimeout(resolve, ms));
4260
+ }
4261
+
4262
+ // src/lib/core/sitemap/entries.ts
4263
+ async function probeSitemapEntries(locs, options) {
4264
+ const byUrl = /* @__PURE__ */ new Map();
4265
+ const toFetch = [];
4266
+ for (const loc of locs) {
4267
+ if (byUrl.has(loc)) continue;
4268
+ const crawled = options.crawled.get(loc);
4269
+ if (crawled !== void 0) {
4270
+ byUrl.set(loc, { url: loc, status: crawled, via: "crawl", finalUrl: loc, redirected: false });
4271
+ continue;
4272
+ }
4273
+ const checked = options.checked.get(loc);
4274
+ if (checked) {
4275
+ byUrl.set(loc, fromCheck(loc, checked, "link-audit"));
4276
+ continue;
4277
+ }
4278
+ toFetch.push(loc);
4279
+ }
4280
+ const budget = toFetch.slice(0, options.maxProbes);
4281
+ const unprobed = toFetch.length - budget.length;
4282
+ const queue = [...budget];
4283
+ const workers = Math.min(options.concurrency ?? 6, queue.length);
4284
+ await Promise.all(
4285
+ Array.from({ length: workers }, async () => {
4286
+ for (let url = queue.pop(); url !== void 0; url = queue.pop()) {
4287
+ const check = await checkLink(url, {
4288
+ signal: options.signal,
4289
+ timeoutMs: options.timeoutMs,
4290
+ allowInsecureTls: options.allowInsecureTls
4291
+ }).catch(() => void 0);
4292
+ if (check) byUrl.set(url, fromCheck(url, check, "probe"));
4293
+ }
4294
+ })
4295
+ );
4296
+ return { byUrl, unprobed };
4297
+ }
4298
+ function fromCheck(url, check, via) {
4299
+ return {
4300
+ url,
4301
+ status: check.status,
4302
+ via,
4303
+ finalUrl: check.finalUrl,
4304
+ // A redirect that resolved, or one that did not: either way the entry did
4305
+ // not name the URL that answers, which is what the rule is about.
4306
+ redirected: check.finalUrl !== url || check.redirectChain.length > 0
4307
+ };
4308
+ }
4309
+
4310
+ // src/lib/core/clusters.ts
4311
+ function normalise2(raw) {
4312
+ let url;
2599
4313
  try {
2600
4314
  url = new URL(raw);
2601
4315
  } catch {
@@ -2613,7 +4327,7 @@ function indexOf(rows, conflicts, refused) {
2613
4327
  return {
2614
4328
  rows,
2615
4329
  routeOf: (url) => {
2616
- const key = normalise(url);
4330
+ const key = normalise2(url);
2617
4331
  return key === null ? void 0 : rows.get(key);
2618
4332
  },
2619
4333
  size: clusterCount(rows),
@@ -2633,7 +4347,7 @@ function buildClusterIndex(entries) {
2633
4347
  refused++;
2634
4348
  continue;
2635
4349
  }
2636
- const anchorUrl = normalise(xDefault.href);
4350
+ const anchorUrl = normalise2(xDefault.href);
2637
4351
  if (!anchorUrl) {
2638
4352
  refused++;
2639
4353
  continue;
@@ -2641,7 +4355,7 @@ function buildClusterIndex(entries) {
2641
4355
  const { route: anchorRoute } = splitRoute(new URL(anchorUrl).pathname);
2642
4356
  const members = [entry.loc, ...alternates.map((a) => a.href)];
2643
4357
  for (const member of members) {
2644
- const url = normalise(member);
4358
+ const url = normalise2(member);
2645
4359
  if (!url) continue;
2646
4360
  const existing = rows.get(url);
2647
4361
  if (existing === void 0) {
@@ -2656,12 +4370,12 @@ function buildClusterIndex(entries) {
2656
4370
  function readHeads(pages) {
2657
4371
  const seen = /* @__PURE__ */ new Map();
2658
4372
  for (const page of pages) {
2659
- const self = normalise(page.fetch.finalUrl);
4373
+ const self = normalise2(page.fetch.finalUrl);
2660
4374
  if (self === null || seen.has(self)) continue;
2661
4375
  const peers = /* @__PURE__ */ new Set();
2662
4376
  const xDefaults = /* @__PURE__ */ new Set();
2663
4377
  for (const alt of page.links.alternates) {
2664
- const href = normalise(alt.href);
4378
+ const href = normalise2(alt.href);
2665
4379
  if (href === null) continue;
2666
4380
  if (alt.isXDefault) xDefaults.add(href);
2667
4381
  else if (href !== self) peers.add(href);
@@ -2826,6 +4540,98 @@ function selectByStructure(urls, options = {}) {
2826
4540
  };
2827
4541
  }
2828
4542
 
4543
+ // src/lib/core/probes/assets.ts
4544
+ var HEAD_BYTES = 8 * 1024;
4545
+ function isImageContentType(contentType) {
4546
+ if (!contentType) return false;
4547
+ return contentType.startsWith("image/") || contentType === "application/ico" || contentType === "application/x-ico";
4548
+ }
4549
+ async function probeAsset(url, options = {}) {
4550
+ const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 8e3);
4551
+ try {
4552
+ const res = await fetch(url, {
4553
+ signal,
4554
+ redirect: "follow",
4555
+ headers: { range: `bytes=0-${HEAD_BYTES - 1}`, accept: "image/*,*/*;q=0.8" }
4556
+ });
4557
+ const contentType = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
4558
+ const served = res.status === 200 || res.status === 206;
4559
+ const ok = served && isImageContentType(contentType);
4560
+ const head = served ? await readHead(res) : void 0;
4561
+ const sizes = head ? decodeSizes(head) : void 0;
4562
+ return { url, status: res.status, ok, contentType, ...sizes ? { sizes } : {} };
4563
+ } catch {
4564
+ return { url, status: 0, ok: false };
4565
+ } finally {
4566
+ cleanup();
4567
+ }
4568
+ }
4569
+ async function readHead(res) {
4570
+ if (!res.body) return void 0;
4571
+ const reader = res.body.getReader();
4572
+ const chunks = [];
4573
+ let total = 0;
4574
+ try {
4575
+ while (total < HEAD_BYTES) {
4576
+ const { done, value } = await reader.read();
4577
+ if (done) break;
4578
+ if (value) {
4579
+ chunks.push(value);
4580
+ total += value.byteLength;
4581
+ }
4582
+ }
4583
+ } catch {
4584
+ return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
4585
+ } finally {
4586
+ await reader.cancel().catch(() => void 0);
4587
+ }
4588
+ return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
4589
+ }
4590
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
4591
+ function decodeSizes(head) {
4592
+ if (head.length >= 24 && head.subarray(0, 8).equals(PNG_SIGNATURE)) {
4593
+ return [{ width: head.readUInt32BE(16), height: head.readUInt32BE(20) }];
4594
+ }
4595
+ if (head.length >= 6 && head.readUInt16LE(0) === 0 && head.readUInt16LE(2) === 1) {
4596
+ const count2 = head.readUInt16LE(4);
4597
+ if (count2 === 0 || head.length < 6 + 16 * count2) return void 0;
4598
+ const sizes = [];
4599
+ for (let index = 0; index < count2; index++) {
4600
+ const at = 6 + 16 * index;
4601
+ sizes.push({ width: head[at] || 256, height: head[at + 1] || 256 });
4602
+ }
4603
+ return sizes;
4604
+ }
4605
+ return void 0;
4606
+ }
4607
+
4608
+ // src/lib/core/probes/favicon.ts
4609
+ async function probeFavicon(origin, options = {}) {
4610
+ const url = new URL("/favicon.ico", origin).toString();
4611
+ const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 5e3);
4612
+ try {
4613
+ let res = await fetch(url, { method: "HEAD", signal, redirect: "follow" });
4614
+ if (res.status === 405 || res.status === 501) {
4615
+ res = await fetch(url, { signal, redirect: "follow" });
4616
+ }
4617
+ const contentType = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
4618
+ return {
4619
+ url,
4620
+ status: res.status,
4621
+ found: res.ok && isIconType(contentType),
4622
+ contentType
4623
+ };
4624
+ } catch {
4625
+ return { url, status: 0, found: false };
4626
+ } finally {
4627
+ cleanup();
4628
+ }
4629
+ }
4630
+ function isIconType(contentType) {
4631
+ if (!contentType) return false;
4632
+ return contentType.startsWith("image/") || contentType === "application/ico" || contentType === "application/x-ico";
4633
+ }
4634
+
2829
4635
  // src/lib/rules/advisory.ts
2830
4636
  function resolveEvidencePath(extraction, path) {
2831
4637
  let current = extraction;
@@ -2974,176 +4780,19 @@ var ogImageRepresentative = {
2974
4780
  title: "The `og:image` represents this page and survives the unfurl crop",
2975
4781
  why: "The preview image is the whole payload of a shared link. A generic site-wide banner, or artwork whose subject sits outside the ~1.91:1 crop, is present enough to pass every mechanical check and still communicate nothing.",
2976
4782
  rigor: "guideline",
2977
- sources: ["ogp", "meta-og-sharing"],
2978
- reads: ["openGraph.images", "openGraph.title", "document.title", "http.finalUrl"],
2979
- relates: ["og.image.missing"],
2980
- appliesTo: (ex) => ex.openGraph.images.length > 0,
2981
- prose: "Does the og:image represent what this page is about, rather than being a site-wide default, and does its subject survive being cropped to the 1.91:1 aspect ratio consumers render?"
2982
- };
2983
- var PROSE_RULES = [
2984
- descriptionAccurate,
2985
- langMatchesContent,
2986
- ogImageRepresentative,
2987
- titleDescriptive
2988
- ];
2989
- var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
2990
-
2991
- // src/lib/core/net/fetch-url.ts
2992
- var DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
2993
- var DEFAULT_TIMEOUT_MS = 8e3;
2994
- var DEFAULT_MAX_BYTES = 3 * 1024 * 1024;
2995
- var DEFAULT_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
2996
- var TEXTY = /(text\/|application\/(xhtml\+xml|xml|json|.*\+xml|.*\+json|javascript))/;
2997
- async function fetchUrl(url, options = {}) {
2998
- const method = options.method ?? "GET";
2999
- const redirect = options.redirect ?? "follow";
3000
- const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
3001
- const startedAt = Date.now();
3002
- const { signal, cleanup } = combineSignals(
3003
- options.signal,
3004
- options.timeoutMs ?? DEFAULT_TIMEOUT_MS
3005
- );
3006
- const restoreTls = relaxTlsIfRequested3(options.allowInsecureTls);
3007
- try {
3008
- const res = await fetch(url, {
3009
- method,
3010
- signal,
3011
- redirect,
3012
- headers: {
3013
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
3014
- accept: options.accept ?? DEFAULT_ACCEPT
3015
- }
3016
- });
3017
- const contentType = normaliseContentType(res.headers.get("content-type"));
3018
- const result = {
3019
- requestedUrl: url,
3020
- finalUrl: res.url || url,
3021
- status: res.status,
3022
- redirected: res.redirected,
3023
- redirectChain: [],
3024
- contentType,
3025
- durationMs: Date.now() - startedAt
3026
- };
3027
- const retryAfter = res.headers.get("retry-after");
3028
- if (retryAfter) result.retryAfter = retryAfter;
3029
- if (redirect === "manual" && isRedirectStatus(res.status)) {
3030
- const location = res.headers.get("location");
3031
- if (location) {
3032
- try {
3033
- const next = new URL(location, url).toString();
3034
- result.finalUrl = next;
3035
- result.redirectChain = [next];
3036
- result.redirected = true;
3037
- } catch {
3038
- }
3039
- }
3040
- await res.body?.cancel().catch(() => void 0);
3041
- return result;
3042
- }
3043
- if (method === "HEAD") {
3044
- await res.body?.cancel().catch(() => void 0);
3045
- return result;
3046
- }
3047
- if (contentType && !TEXTY.test(contentType)) {
3048
- await res.body?.cancel().catch(() => void 0);
3049
- return result;
3050
- }
3051
- const { text, truncated } = await readCappedText(res, maxBytes);
3052
- result.body = text;
3053
- if (truncated) result.truncated = true;
3054
- return result;
3055
- } catch (err) {
3056
- return {
3057
- requestedUrl: url,
3058
- finalUrl: url,
3059
- status: 0,
3060
- redirected: false,
3061
- redirectChain: [],
3062
- reason: classifyError(err, options.signal),
3063
- durationMs: Date.now() - startedAt
3064
- };
3065
- } finally {
3066
- cleanup();
3067
- restoreTls();
3068
- }
3069
- }
3070
- function isRedirectStatus(status) {
3071
- return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
3072
- }
3073
- function normaliseContentType(raw) {
3074
- if (!raw) return void 0;
3075
- const semi = raw.indexOf(";");
3076
- return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase() || void 0;
3077
- }
3078
- async function readCappedText(res, maxBytes) {
3079
- const reader = res.body?.getReader();
3080
- if (!reader) {
3081
- const text = await res.text();
3082
- if (text.length > maxBytes) {
3083
- return { text: text.slice(0, maxBytes), truncated: true };
3084
- }
3085
- return { text, truncated: false };
3086
- }
3087
- const chunks = [];
3088
- let received = 0;
3089
- let truncated = false;
3090
- for (; ; ) {
3091
- const { done, value } = await reader.read();
3092
- if (done) break;
3093
- if (!value) continue;
3094
- chunks.push(value);
3095
- received += value.byteLength;
3096
- if (received >= maxBytes) {
3097
- truncated = true;
3098
- await reader.cancel().catch(() => void 0);
3099
- break;
3100
- }
3101
- }
3102
- const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
3103
- const sliced = buf.byteLength > maxBytes ? buf.subarray(0, maxBytes) : buf;
3104
- return { text: sliced.toString("utf8"), truncated };
3105
- }
3106
- function classifyError(err, callerSignal) {
3107
- if (isAbortError(err)) {
3108
- return callerSignal?.aborted ? "abort" : "timeout";
3109
- }
3110
- const code = errorCode(err);
3111
- if (code) {
3112
- if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "dns";
3113
- if (code.startsWith("CERT_") || code.includes("SELF_SIGNED") || code.includes("CERT")) {
3114
- return "tls";
3115
- }
3116
- }
3117
- const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
3118
- if (message.includes("certificate") || message.includes("tls") || message.includes("ssl")) {
3119
- return "tls";
3120
- }
3121
- return "network";
3122
- }
3123
- function isAbortError(err) {
3124
- return err instanceof Error && err.name === "AbortError";
3125
- }
3126
- function errorCode(err) {
3127
- if (err && typeof err === "object") {
3128
- const direct = err.code;
3129
- if (typeof direct === "string") return direct;
3130
- const cause = err.cause;
3131
- if (cause && typeof cause === "object") {
3132
- const causeCode = cause.code;
3133
- if (typeof causeCode === "string") return causeCode;
3134
- }
3135
- }
3136
- return void 0;
3137
- }
3138
- function relaxTlsIfRequested3(enabled) {
3139
- if (!enabled) return () => void 0;
3140
- const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3141
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
3142
- return () => {
3143
- if (previous === void 0) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3144
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous;
3145
- };
3146
- }
4783
+ sources: ["ogp", "meta-og-sharing"],
4784
+ reads: ["openGraph.images", "openGraph.title", "document.title", "http.finalUrl"],
4785
+ relates: ["og.image.missing"],
4786
+ appliesTo: (ex) => ex.openGraph.images.length > 0,
4787
+ prose: "Does the og:image represent what this page is about, rather than being a site-wide default, and does its subject survive being cropped to the 1.91:1 aspect ratio consumers render?"
4788
+ };
4789
+ var PROSE_RULES = [
4790
+ descriptionAccurate,
4791
+ langMatchesContent,
4792
+ ogImageRepresentative,
4793
+ titleDescriptive
4794
+ ];
4795
+ var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
3147
4796
 
3148
4797
  // src/lib/core/links/extract.ts
3149
4798
  import * as cheerio3 from "cheerio";
@@ -3228,188 +4877,6 @@ function resolveBase2($, pageUrl) {
3228
4877
  return pageUrl;
3229
4878
  }
3230
4879
 
3231
- // src/lib/core/links/classify.ts
3232
- function classifyLink(input) {
3233
- if (input.status === 0) return "broken";
3234
- if (input.loop) return "broken";
3235
- if (input.softNotFound) return "warning";
3236
- if (input.status === 403 || input.status === 429) return "blocked";
3237
- if (input.status >= 400) return "broken";
3238
- if (input.status >= 200 && input.status < 300) {
3239
- return input.redirected ? "redirect" : "ok";
3240
- }
3241
- return "broken";
3242
- }
3243
-
3244
- // src/lib/core/links/check.ts
3245
- var DEFAULTS2 = {
3246
- timeoutMs: 8e3,
3247
- maxRedirects: 10,
3248
- retries: 1,
3249
- backoffBaseMs: 300,
3250
- maxBackoffMs: 3e3,
3251
- softBodyBytes: 16 * 1024
3252
- };
3253
- var NOT_FOUND_RE = /\b(not\s+found|page\s+doesn'?t\s+exist|404|no\s+such\s+page)\b/i;
3254
- var SOFT_404_BODY_LIMIT = 2048;
3255
- async function checkLink(url, options = {}) {
3256
- const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
3257
- const started = Date.now();
3258
- const scheme = schemeOf(url);
3259
- if (scheme !== "http" && scheme !== "https") {
3260
- return {
3261
- url,
3262
- finalUrl: url,
3263
- status: 0,
3264
- verdict: "skipped",
3265
- method: "GET",
3266
- redirectChain: [],
3267
- reason: scheme ? `unsupported scheme: ${scheme}` : "not a URL",
3268
- checkedAt,
3269
- durationMs: Date.now() - started
3270
- };
3271
- }
3272
- const outcome = await probeWithRetry(url, options);
3273
- const softNotFound = isSoftNotFound(outcome);
3274
- const verdict = classifyLink({
3275
- status: outcome.status,
3276
- redirected: outcome.redirectChain.length > 0,
3277
- reason: outcome.reason,
3278
- softNotFound,
3279
- loop: outcome.loop
3280
- });
3281
- return {
3282
- url,
3283
- finalUrl: outcome.finalUrl,
3284
- status: outcome.status,
3285
- verdict,
3286
- method: outcome.method,
3287
- redirectChain: outcome.redirectChain,
3288
- reason: describeReason(outcome, softNotFound),
3289
- checkedAt,
3290
- durationMs: Date.now() - started
3291
- };
3292
- }
3293
- async function probeWithRetry(url, options) {
3294
- const retries = options.retries ?? DEFAULTS2.retries;
3295
- const sleep = options.sleep ?? defaultSleep;
3296
- let outcome = await probeWithHeadFallback(url, options);
3297
- for (let attempt = 0; attempt < retries; attempt++) {
3298
- if (!isTransient(outcome)) break;
3299
- if (options.signal?.aborted) break;
3300
- const delay = backoffDelay(outcome, attempt, options);
3301
- if (delay > 0) await sleep(delay);
3302
- outcome = await probeWithHeadFallback(url, options);
3303
- }
3304
- return outcome;
3305
- }
3306
- async function probeWithHeadFallback(url, options) {
3307
- const head = await followRedirects(url, "HEAD", options);
3308
- if (headRejected(head) || head.status >= 200 && head.status < 300) {
3309
- const get = await followRedirects(url, "GET", options);
3310
- if (get.status === 0 && head.status !== 0 && !headRejected(head)) return head;
3311
- return get;
3312
- }
3313
- return head;
3314
- }
3315
- function headRejected(o) {
3316
- return o.status === 405 || o.status === 501 || o.status === 403 || o.status === 0;
3317
- }
3318
- async function followRedirects(url, method, options) {
3319
- const maxRedirects = options.maxRedirects ?? DEFAULTS2.maxRedirects;
3320
- const chain = [];
3321
- const visited = /* @__PURE__ */ new Set([url]);
3322
- let current = url;
3323
- for (let hop = 0; hop <= maxRedirects; hop++) {
3324
- const res = await fetchUrl(current, {
3325
- method,
3326
- redirect: "manual",
3327
- timeoutMs: options.timeoutMs ?? DEFAULTS2.timeoutMs,
3328
- allowInsecureTls: options.allowInsecureTls,
3329
- userAgent: options.userAgent,
3330
- maxBytes: method === "GET" ? options.softBodyBytes ?? DEFAULTS2.softBodyBytes : void 0
3331
- });
3332
- if (res.status === 0) {
3333
- return {
3334
- status: 0,
3335
- finalUrl: current,
3336
- redirectChain: chain,
3337
- reason: res.reason,
3338
- loop: false,
3339
- method
3340
- };
3341
- }
3342
- const next = res.redirectChain[0];
3343
- if (next) {
3344
- chain.push(next);
3345
- if (visited.has(next)) {
3346
- return { status: res.status, finalUrl: next, redirectChain: chain, loop: true, method };
3347
- }
3348
- visited.add(next);
3349
- current = next;
3350
- continue;
3351
- }
3352
- return {
3353
- status: res.status,
3354
- finalUrl: res.finalUrl,
3355
- redirectChain: chain,
3356
- loop: false,
3357
- body: res.body,
3358
- retryAfter: res.retryAfter,
3359
- method
3360
- };
3361
- }
3362
- return { status: 0, finalUrl: current, redirectChain: chain, loop: true, method };
3363
- }
3364
- function isSoftNotFound(o) {
3365
- if (o.status < 200 || o.status >= 300) return false;
3366
- if (o.body === void 0) return false;
3367
- if (o.body.length > SOFT_404_BODY_LIMIT) return false;
3368
- return NOT_FOUND_RE.test(o.body);
3369
- }
3370
- function isTransient(o) {
3371
- if (o.status === 0) return true;
3372
- if (o.status === 429) return true;
3373
- if (o.status >= 500) return true;
3374
- return false;
3375
- }
3376
- function backoffDelay(o, attempt, options) {
3377
- const max = options.maxBackoffMs ?? DEFAULTS2.maxBackoffMs;
3378
- const retryAfterMs = parseRetryAfter(o.retryAfter);
3379
- if (retryAfterMs !== void 0) return Math.min(retryAfterMs, max);
3380
- const base = options.backoffBaseMs ?? DEFAULTS2.backoffBaseMs;
3381
- const exp = base * 2 ** attempt;
3382
- const jitter = Math.floor(Math.random() * base);
3383
- return Math.min(exp + jitter, max);
3384
- }
3385
- function parseRetryAfter(value) {
3386
- if (!value) return void 0;
3387
- const seconds = Number(value);
3388
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
3389
- const date = Date.parse(value);
3390
- if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
3391
- return void 0;
3392
- }
3393
- function describeReason(o, softNotFound) {
3394
- if (o.reason) return o.reason;
3395
- if (o.loop) return "redirect loop";
3396
- if (softNotFound) return "soft-404";
3397
- if (o.status === 429) return "429 rate-limited";
3398
- if (o.status === 403) return "403 forbidden";
3399
- if (o.status >= 400) return `${o.status}`;
3400
- return void 0;
3401
- }
3402
- function schemeOf(url) {
3403
- try {
3404
- return new URL(url).protocol.replace(/:$/, "").toLowerCase();
3405
- } catch {
3406
- return null;
3407
- }
3408
- }
3409
- function defaultSleep(ms) {
3410
- return new Promise((resolve) => setTimeout(resolve, ms));
3411
- }
3412
-
3413
4880
  // src/lib/core/links/types.ts
3414
4881
  function emptyVerdictSummary() {
3415
4882
  return { ok: 0, redirect: 0, broken: 0, blocked: 0, warning: 0, skipped: 0 };
@@ -3493,6 +4960,18 @@ async function runLinkAudit(discovery, options = {}) {
3493
4960
  {
3494
4961
  concurrency: options.checkConcurrency ?? DEFAULTS3.checkConcurrency,
3495
4962
  perHost: options.maxPerHost ?? DEFAULTS3.maxPerHost,
4963
+ // The origin is exempt, and the scan phase above is the precedent: it
4964
+ // fetches these same pages at `scanConcurrency` with `perHost: Infinity`.
4965
+ // Capping the check phase at 3 against that host made the two phases
4966
+ // disagree about the same server, and on a deployed site it is where the
4967
+ // audit spends itself: measured on openfinanceguide, 3821 same-origin
4968
+ // links at ~700 ms a round trip, three at a time — 14.8 minutes of an
4969
+ // 18.5-minute job. Against a local `--start` run the cap never binds,
4970
+ // which is why the plan's own 7-minute figure held and CI's did not.
4971
+ //
4972
+ // `checkConcurrency` still bounds it: at most 8 in flight, where the scan
4973
+ // phase already runs 4.
4974
+ unlimitedHost: hostOf(discovery.origin),
3496
4975
  signal: options.signal
3497
4976
  },
3498
4977
  async (url) => {
@@ -3576,7 +5055,8 @@ function mapWithHostCaps(items, opts, worker, hostOf2) {
3576
5055
  let pickIndex = -1;
3577
5056
  for (let k = 0; k < pending.length; k++) {
3578
5057
  const entry = pending[k];
3579
- if ((hostActive.get(entry.host) ?? 0) < perHost) {
5058
+ const cap = entry.host === opts.unlimitedHost ? Infinity : perHost;
5059
+ if ((hostActive.get(entry.host) ?? 0) < cap) {
3580
5060
  pickIndex = k;
3581
5061
  break;
3582
5062
  }
@@ -3709,6 +5189,71 @@ function exitCode(report, failOn = "warning") {
3709
5189
  if (failOn === "error") return report.summary.verdict === "red" ? 1 : 0;
3710
5190
  return 1;
3711
5191
  }
5192
+ async function attachManifests(pages, options) {
5193
+ const wanted = new Set(
5194
+ pages.map((page) => page.links.manifest?.href).filter((href) => Boolean(href))
5195
+ );
5196
+ if (wanted.size === 0) return;
5197
+ const probed = /* @__PURE__ */ new Map();
5198
+ await Promise.all(
5199
+ [...wanted].map(async (href) => {
5200
+ const probe = await probeManifest(href, options).catch(() => void 0);
5201
+ if (probe) probed.set(href, probe);
5202
+ })
5203
+ );
5204
+ for (const page of pages) {
5205
+ const href = page.links.manifest?.href;
5206
+ const probe = href ? probed.get(href) : void 0;
5207
+ if (probe) page.probes = { ...page.probes, manifest: probe };
5208
+ }
5209
+ }
5210
+ async function probeAssets(pages, options) {
5211
+ const sameOrigin2 = (url) => {
5212
+ try {
5213
+ return new URL(url).origin === new URL(options.origin).origin;
5214
+ } catch {
5215
+ return false;
5216
+ }
5217
+ };
5218
+ const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal || sameOrigin2(url));
5219
+ const wanted = /* @__PURE__ */ new Set();
5220
+ for (const page of pages) {
5221
+ for (const image of page.openGraph.images) {
5222
+ const url = image.url.value.trim();
5223
+ if (wantedUrl(url)) wanted.add(url);
5224
+ }
5225
+ for (const icon of page.links.icons) {
5226
+ if (wantedUrl(icon.href)) wanted.add(icon.href);
5227
+ }
5228
+ }
5229
+ if (wanted.size === 0) return;
5230
+ const queue = [...wanted];
5231
+ const probed = /* @__PURE__ */ new Map();
5232
+ const workers = Math.min(options.concurrency ?? 6, queue.length);
5233
+ await Promise.all(
5234
+ Array.from({ length: workers }, async () => {
5235
+ for (let url = queue.pop(); url !== void 0; url = queue.pop()) {
5236
+ const probe = await probeAsset(url, {
5237
+ signal: options.signal,
5238
+ timeoutMs: options.timeoutMs
5239
+ }).catch(() => void 0);
5240
+ if (probe) probed.set(url, probe);
5241
+ }
5242
+ })
5243
+ );
5244
+ for (const page of pages) {
5245
+ const mine = {};
5246
+ for (const image of page.openGraph.images) {
5247
+ const probe = probed.get(image.url.value.trim());
5248
+ if (probe) mine[probe.url] = probe;
5249
+ }
5250
+ for (const icon of page.links.icons) {
5251
+ const probe = probed.get(icon.href);
5252
+ if (probe) mine[probe.url] = probe;
5253
+ }
5254
+ page.assets = mine;
5255
+ }
5256
+ }
3712
5257
  function dropCanonicalDuplicates(pages) {
3713
5258
  const crawled = new Set(pages.map((p) => routeKey(p.fetch.finalUrl)));
3714
5259
  const kept = pages.filter((page) => {
@@ -3754,6 +5299,10 @@ async function runAudit(inputUrl, options = {}) {
3754
5299
  signal: options.signal,
3755
5300
  timeoutMs: options.timeoutMs
3756
5301
  }).catch(() => void 0);
5302
+ const favicon = await probeFavicon(origin, {
5303
+ signal: options.signal,
5304
+ timeoutMs: options.timeoutMs
5305
+ }).catch(() => void 0);
3757
5306
  const crawlResult = await crawl({
3758
5307
  entryUrl: entry,
3759
5308
  seedUrls: sitemapUrls,
@@ -3800,6 +5349,13 @@ async function runAudit(inputUrl, options = {}) {
3800
5349
  status: 0
3801
5350
  }))
3802
5351
  ];
5352
+ await attachManifests(htmlPages, { signal: options.signal, timeoutMs: options.timeoutMs });
5353
+ await probeAssets(htmlPages, {
5354
+ origin,
5355
+ checkExternal: options.checkExternal ?? true,
5356
+ signal: options.signal,
5357
+ timeoutMs: options.timeoutMs
5358
+ });
3803
5359
  const seoIssues = [];
3804
5360
  const conformanceRows = [];
3805
5361
  const advisories = [];
@@ -3832,7 +5388,14 @@ async function runAudit(inputUrl, options = {}) {
3832
5388
  severity: issue.severity,
3833
5389
  message: issue.message,
3834
5390
  why: rule?.title,
3835
- fix: issue.fix?.snippet
5391
+ fix: issue.fix?.snippet,
5392
+ // The fingerprint above is unchanged on purpose: it keys on the rule,
5393
+ // the route and the occurrence, so carrying more of the finding cannot
5394
+ // move a baseline entry (`./diff.ts` matches on `id` alone).
5395
+ rigor: issue.rigor,
5396
+ sources: issue.sources,
5397
+ observed: issue.observed,
5398
+ expected: issue.expected
3836
5399
  });
3837
5400
  }
3838
5401
  }
@@ -3879,6 +5442,33 @@ async function runAudit(inputUrl, options = {}) {
3879
5442
  ),
3880
5443
  ...issue
3881
5444
  }));
5445
+ const linkDiscovery = syntheticDiscovery(origin, entry, okPages, crawlResult.truncated);
5446
+ const linkReport = await runLinkAudit(linkDiscovery, {
5447
+ allowInsecureTls: options.allowInsecureTls,
5448
+ checkExternal,
5449
+ timeoutMs: options.timeoutMs,
5450
+ maxPages: effectiveMaxPages,
5451
+ signal: options.signal,
5452
+ onProgress: (p) => options.onProgress?.({
5453
+ phase: p.phase === "scan" ? "scan" : "links",
5454
+ done: p.done,
5455
+ total: p.total
5456
+ })
5457
+ });
5458
+ warnings.push(...linkReport.diagnostics.warnings);
5459
+ const sitemapEntries = discovery ? await probeSitemapEntries(
5460
+ discovery.urls.map((entry2) => entry2.loc),
5461
+ {
5462
+ crawled: new Map(
5463
+ crawlResult.pages.map((page) => [page.fetch.finalUrl, page.fetch.status])
5464
+ ),
5465
+ checked: new Map(Object.values(linkReport.checks).map((check) => [check.url, check])),
5466
+ maxProbes: effectiveMaxPages,
5467
+ signal: options.signal,
5468
+ timeoutMs: options.timeoutMs,
5469
+ allowInsecureTls: options.allowInsecureTls
5470
+ }
5471
+ ) : void 0;
3882
5472
  const siteContext = {
3883
5473
  origin,
3884
5474
  pages: htmlPages,
@@ -3886,6 +5476,8 @@ async function runAudit(inputUrl, options = {}) {
3886
5476
  localeAxis,
3887
5477
  discovery,
3888
5478
  robots,
5479
+ favicon,
5480
+ sitemapEntries,
3889
5481
  // Same index the matrix was built from, so a rule that groups by route
3890
5482
  // and the grid that reports holes cannot disagree about which URLs are
3891
5483
  // one page.
@@ -3904,23 +5496,11 @@ async function runAudit(inputUrl, options = {}) {
3904
5496
  severity: draft.severity,
3905
5497
  message: draft.message,
3906
5498
  why: rule?.summary,
3907
- fix: draft.fix?.snippet
5499
+ fix: draft.fix?.snippet,
5500
+ rigor: draft.rigor,
5501
+ sources: draft.sources
3908
5502
  };
3909
5503
  });
3910
- const linkDiscovery = syntheticDiscovery(origin, entry, okPages, crawlResult.truncated);
3911
- const linkReport = await runLinkAudit(linkDiscovery, {
3912
- allowInsecureTls: options.allowInsecureTls,
3913
- checkExternal,
3914
- timeoutMs: options.timeoutMs,
3915
- maxPages: effectiveMaxPages,
3916
- signal: options.signal,
3917
- onProgress: (p) => options.onProgress?.({
3918
- phase: p.phase === "scan" ? "scan" : "links",
3919
- done: p.done,
3920
- total: p.total
3921
- })
3922
- });
3923
- warnings.push(...linkReport.diagnostics.warnings);
3924
5504
  const brokenLinks = [];
3925
5505
  for (const { pageUrl, broken } of linkReport.brokenByPage) {
3926
5506
  for (const check of broken) {
@@ -4410,7 +5990,8 @@ function rollupRows(issues, c) {
4410
5990
  const lines = [];
4411
5991
  for (const issue of issues) {
4412
5992
  const sev = issue.severity === "error" ? c(ANSI2.red, "error") : issue.severity === "warning" ? c(ANSI2.yellow, "warn ") : c(ANSI2.dim, "info ");
4413
- lines.push(` ${sev} ${c(ANSI2.cyan, issue.ruleId)} ${c(ANSI2.dim, `\xD7${issue.count}`)}`);
5993
+ const rigor = issue.rigor ? c(ANSI2.dim, ` [${issue.rigor}]`) : "";
5994
+ lines.push(` ${sev} ${c(ANSI2.cyan, issue.ruleId)}${rigor} ${c(ANSI2.dim, `\xD7${issue.count}`)}`);
4414
5995
  if (issue.why) lines.push(` ${c(ANSI2.dim, stripBackticks2(issue.why))}`);
4415
5996
  if (issue.fix) {
4416
5997
  const [head, ...rest] = issue.fix.split("\n");
@@ -4489,6 +6070,7 @@ function rollupByRule(issues) {
4489
6070
  groups.set(issue.ruleId, {
4490
6071
  ruleId: issue.ruleId,
4491
6072
  severity: issue.severity,
6073
+ rigor: issue.rigor,
4492
6074
  why: issue.why,
4493
6075
  fix: issue.fix,
4494
6076
  sample: issue.message,
@@ -4707,4 +6289,4 @@ export {
4707
6289
  summarize,
4708
6290
  Logger
4709
6291
  };
4710
- //# sourceMappingURL=chunk-SKX7UCGC.js.map
6292
+ //# sourceMappingURL=chunk-DKGBLJ4H.js.map