@goflag/cli 0.2.9 → 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) {
@@ -2594,6 +2659,70 @@ function sortLocales(locales) {
2594
2659
  return [...xDefault, ...rest];
2595
2660
  }
2596
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
+
2597
2726
  // src/lib/rules/site-rules.ts
2598
2727
  function isMultilingual(site) {
2599
2728
  return site.localeAxis.multilingual;
@@ -2735,7 +2864,9 @@ var robotsBlocksSite = {
2735
2864
  id: "robots.blocks-site",
2736
2865
  severity: "error",
2737
2866
  summary: "`robots.txt` must not forbid crawling a site that asks to be indexed",
2738
- 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,
2739
2870
  check: ({ site, issue }) => {
2740
2871
  const robotsUrl = site.robots?.url ?? `${site.origin}/robots.txt`;
2741
2872
  const asking = site.pages.filter((page) => metaRobotsTokens(page).has("index"));
@@ -2801,75 +2932,653 @@ var iconsIcoMissing = {
2801
2932
  });
2802
2933
  }
2803
2934
  };
2804
- var SITE_RULES = [
2805
- hreflangMissing,
2806
- hreflangSitemapMismatch,
2807
- iconsIcoMissing,
2808
- robotsBlocksSite
2809
- ];
2810
- function getSiteRule(id) {
2811
- return SITE_RULES.find((rule) => rule.id === id);
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
+ }
2812
3245
  }
2813
-
2814
- // src/lib/core/lint-site.ts
2815
- function lintSite(site, rules = SITE_RULES) {
2816
- const drafts = [];
2817
- for (const rule of rules) {
2818
- if (rule.appliesTo && !rule.appliesTo(site)) continue;
2819
- let result;
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;
2820
3251
  try {
2821
- result = rule.check({
2822
- site,
2823
- issue: (input) => ({
2824
- ruleId: rule.id,
2825
- severity: input.severity ?? rule.severity,
2826
- message: input.message,
2827
- origin: input.origin,
2828
- fix: input.fix,
2829
- suggestion: input.suggestion,
2830
- pageUrl: input.pageUrl
2831
- })
2832
- });
2833
- } catch (err) {
2834
- drafts.push({
2835
- ruleId: "engine.site-rule-crashed",
2836
- severity: "info",
2837
- message: `Site rule \`${rule.id}\` threw: ${err instanceof Error ? err.message : String(err)}`,
2838
- pageUrl: site.origin
2839
- });
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 {
2840
3264
  continue;
2841
3265
  }
2842
- if (!result) continue;
2843
- if (Array.isArray(result)) drafts.push(...result);
2844
- else drafts.push(result);
2845
3266
  }
2846
- return sortSiteIssues(drafts);
3267
+ return protocols;
2847
3268
  }
2848
- function sortSiteIssues(drafts) {
2849
- const byPage = [...drafts].sort((a, b) => a.pageUrl.localeCompare(b.pageUrl));
2850
- return sortIssues(byPage);
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;
2851
3284
  }
2852
-
2853
- // src/lib/core/locales.ts
2854
- function localePrefixOf(url) {
2855
- let pathname;
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) {
2856
3303
  try {
2857
- pathname = new URL(url).pathname;
3304
+ const parsed = new URL(url);
3305
+ parsed.hash = "";
3306
+ const path = parsed.pathname.replace(/\/+$/, "");
3307
+ return `${parsed.origin}${path || "/"}${parsed.search}`;
2858
3308
  } catch {
2859
- return null;
3309
+ return void 0;
2860
3310
  }
2861
- const first = pathname.split("/").filter(Boolean)[0];
2862
- if (first && looksLikeLocaleSegment(first)) return first.toLowerCase();
2863
- return null;
2864
3311
  }
2865
- function prefixesIn(urls) {
2866
- const out = /* @__PURE__ */ new Set();
2867
- for (const url of urls ?? []) {
2868
- const locale = localePrefixOf(url);
2869
- if (locale) out.add(locale);
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);
2870
3317
  }
2871
- out.delete("x-default");
2872
- return out;
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
+ }
3486
+ var SITE_RULES = [
3487
+ hreflangMissing,
3488
+ hreflangSitemapMismatch,
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
3513
+ ];
3514
+ function getSiteRule(id) {
3515
+ return SITE_RULES.find((rule) => rule.id === id);
3516
+ }
3517
+
3518
+ // src/lib/core/lint-site.ts
3519
+ function lintSite(site, rules = SITE_RULES) {
3520
+ const drafts = [];
3521
+ for (const rule of rules) {
3522
+ if (rule.appliesTo && !rule.appliesTo(site)) continue;
3523
+ let result;
3524
+ try {
3525
+ result = rule.check({
3526
+ site,
3527
+ issue: (input) => ({
3528
+ ruleId: rule.id,
3529
+ severity: input.severity ?? rule.severity,
3530
+ message: input.message,
3531
+ origin: input.origin,
3532
+ fix: input.fix,
3533
+ suggestion: input.suggestion,
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 ?? [] } : {}
3540
+ })
3541
+ });
3542
+ } catch (err) {
3543
+ drafts.push({
3544
+ ruleId: "engine.site-rule-crashed",
3545
+ severity: "info",
3546
+ message: `Site rule \`${rule.id}\` threw: ${err instanceof Error ? err.message : String(err)}`,
3547
+ pageUrl: site.origin
3548
+ });
3549
+ continue;
3550
+ }
3551
+ if (!result) continue;
3552
+ if (Array.isArray(result)) drafts.push(...result);
3553
+ else drafts.push(result);
3554
+ }
3555
+ return sortSiteIssues(drafts);
3556
+ }
3557
+ function sortSiteIssues(drafts) {
3558
+ const byPage = [...drafts].sort((a, b) => a.pageUrl.localeCompare(b.pageUrl));
3559
+ return sortIssues(byPage);
3560
+ }
3561
+
3562
+ // src/lib/core/locales.ts
3563
+ function localePrefixOf(url) {
3564
+ let pathname;
3565
+ try {
3566
+ pathname = new URL(url).pathname;
3567
+ } catch {
3568
+ return null;
3569
+ }
3570
+ const first = pathname.split("/").filter(Boolean)[0];
3571
+ if (first && looksLikeLocaleSegment(first)) return first.toLowerCase();
3572
+ return null;
3573
+ }
3574
+ function prefixesIn(urls) {
3575
+ const out = /* @__PURE__ */ new Set();
3576
+ for (const url of urls ?? []) {
3577
+ const locale = localePrefixOf(url);
3578
+ if (locale) out.add(locale);
3579
+ }
3580
+ out.delete("x-default");
3581
+ return out;
2873
3582
  }
2874
3583
  function describeCandidates(pages) {
2875
3584
  const byTag = /* @__PURE__ */ new Map();
@@ -3030,14 +3739,15 @@ async function discoverSitemap(baseUrl, options = {}) {
3030
3739
  };
3031
3740
  const robots = await probeRobots(origin, { signal: options.signal });
3032
3741
  diagnostics.robotsFound = robots.found;
3742
+ const declaredUrls = robots.sitemaps.map((entry) => entry.value);
3033
3743
  const candidates = [];
3034
- for (const declared of robots.sitemaps) {
3744
+ for (const declared of declaredUrls) {
3035
3745
  candidates.push({ url: declared, source: "robots", declaredInRobots: true });
3036
3746
  }
3037
3747
  candidates.push({
3038
3748
  url: new URL("/sitemap.xml", origin).toString(),
3039
3749
  source: "well-known",
3040
- declaredInRobots: robots.sitemaps.includes(new URL("/sitemap.xml", origin).toString())
3750
+ declaredInRobots: declaredUrls.includes(new URL("/sitemap.xml", origin).toString())
3041
3751
  });
3042
3752
  candidates.push({
3043
3753
  url: new URL("/sitemap_index.xml", origin).toString(),
@@ -3166,47 +3876,434 @@ function isWellKnownPath(url) {
3166
3876
  return false;
3167
3877
  }
3168
3878
  }
3169
- async function fetchDoc(url, options) {
3170
- const { signal, cleanup } = combineSignals(
3171
- options.signal,
3172
- options.timeoutMs ?? DEFAULTS.timeoutMs
3173
- );
3174
- const restoreTls = relaxTlsIfRequested2(options.allowInsecureTls);
3175
- try {
3176
- const res = await fetch(url, {
3177
- signal,
3178
- redirect: "follow",
3179
- headers: {
3180
- accept: "application/xml,text/xml,application/gzip;q=0.9,*/*;q=0.5"
3181
- }
3182
- });
3183
- if (!res.ok) return { status: res.status };
3184
- const encoding = res.headers.get("content-encoding")?.toLowerCase() ?? "";
3185
- const looksGzip = url.toLowerCase().endsWith(".gz") || encoding.includes("gzip");
3186
- if (looksGzip) {
3187
- const buf = Buffer.from(await res.arrayBuffer());
3188
- try {
3189
- return { status: res.status, body: gunzipSync(buf).toString("utf8") };
3190
- } catch {
3191
- return { status: res.status, body: buf.toString("utf8") };
3192
- }
3879
+ async function fetchDoc(url, options) {
3880
+ const { signal, cleanup } = combineSignals(
3881
+ options.signal,
3882
+ options.timeoutMs ?? DEFAULTS.timeoutMs
3883
+ );
3884
+ const restoreTls = relaxTlsIfRequested2(options.allowInsecureTls);
3885
+ try {
3886
+ const res = await fetch(url, {
3887
+ signal,
3888
+ redirect: "follow",
3889
+ headers: {
3890
+ accept: "application/xml,text/xml,application/gzip;q=0.9,*/*;q=0.5"
3891
+ }
3892
+ });
3893
+ if (!res.ok) return { status: res.status };
3894
+ const encoding = res.headers.get("content-encoding")?.toLowerCase() ?? "";
3895
+ const looksGzip = url.toLowerCase().endsWith(".gz") || encoding.includes("gzip");
3896
+ if (looksGzip) {
3897
+ const buf = Buffer.from(await res.arrayBuffer());
3898
+ try {
3899
+ return { status: res.status, body: gunzipSync(buf).toString("utf8") };
3900
+ } catch {
3901
+ return { status: res.status, body: buf.toString("utf8") };
3902
+ }
3903
+ }
3904
+ return { status: res.status, body: await res.text() };
3905
+ } catch (err) {
3906
+ const reason = err instanceof Error ? err.message : String(err);
3907
+ return { status: 0, error: reason || "network error" };
3908
+ } finally {
3909
+ cleanup();
3910
+ restoreTls();
3911
+ }
3912
+ }
3913
+ function relaxTlsIfRequested2(enabled) {
3914
+ if (!enabled) return () => void 0;
3915
+ const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3916
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
3917
+ return () => {
3918
+ if (previous === void 0) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3919
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous;
3920
+ };
3921
+ }
3922
+
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;
3193
4272
  }
3194
- return { status: res.status, body: await res.text() };
3195
- } catch (err) {
3196
- const reason = err instanceof Error ? err.message : String(err);
3197
- return { status: 0, error: reason || "network error" };
3198
- } finally {
3199
- cleanup();
3200
- restoreTls();
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);
3201
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 };
3202
4297
  }
3203
- function relaxTlsIfRequested2(enabled) {
3204
- if (!enabled) return () => void 0;
3205
- const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3206
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
3207
- return () => {
3208
- if (previous === void 0) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3209
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous;
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
3210
4307
  };
3211
4308
  }
3212
4309
 
@@ -3654,205 +4751,48 @@ var titleDescriptive = {
3654
4751
  var descriptionAccurate = {
3655
4752
  id: "description.accurate",
3656
4753
  kind: "prose",
3657
- category: "meta",
3658
- title: "The meta description summarizes this page truthfully",
3659
- why: "The description is the one sentence you get to write in a search result. A boilerplate line repeated site-wide, or one that promises something the page does not deliver, gets replaced by improvised page text \u2014 or gets the click and loses the visitor.",
3660
- rigor: "guideline",
3661
- sources: ["google-snippet", "moz-meta-description"],
3662
- reads: ["meta.description", "document.title", "http.finalUrl"],
3663
- relates: ["description.missing", "description.length"],
3664
- appliesTo: (ex) => Boolean(ex.meta.description?.value?.trim()),
3665
- prose: "Does the description accurately summarize this page's content, and is it written for this page rather than copied across the site?"
3666
- };
3667
- var langMatchesContent = {
3668
- id: "lang.matches-content",
3669
- kind: "prose",
3670
- category: "i18n",
3671
- title: "The declared `lang` is the language the page is actually written in",
3672
- why: "A wrong `lang` is worse than a missing one: screen readers switch to the wrong pronunciation rules, browsers offer to translate a page that is already in the reader's language, and search engines file the page under the wrong audience. Nothing in the markup contradicts it, so no mechanical check can catch it.",
3673
- rigor: "spec-required",
3674
- sources: ["whatwg-html-lang", "w3c-i18n-language-tags"],
3675
- reads: ["document.lang", "document.title", "meta.description", "openGraph.locale"],
3676
- appliesTo: (ex) => Boolean(ex.document.lang?.value?.trim()),
3677
- prose: "Is the text of this page actually written in the language its `lang` attribute declares?"
3678
- };
3679
- var ogImageRepresentative = {
3680
- id: "og.image.representative",
3681
- kind: "prose",
3682
- category: "opengraph",
3683
- title: "The `og:image` represents this page and survives the unfurl crop",
3684
- 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.",
3685
- rigor: "guideline",
3686
- sources: ["ogp", "meta-og-sharing"],
3687
- reads: ["openGraph.images", "openGraph.title", "document.title", "http.finalUrl"],
3688
- relates: ["og.image.missing"],
3689
- appliesTo: (ex) => ex.openGraph.images.length > 0,
3690
- 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?"
3691
- };
3692
- var PROSE_RULES = [
3693
- descriptionAccurate,
3694
- langMatchesContent,
3695
- ogImageRepresentative,
3696
- titleDescriptive
3697
- ];
3698
- var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
3699
-
3700
- // src/lib/core/net/fetch-url.ts
3701
- 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";
3702
- var DEFAULT_TIMEOUT_MS = 8e3;
3703
- var DEFAULT_MAX_BYTES = 3 * 1024 * 1024;
3704
- var DEFAULT_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
3705
- var TEXTY = /(text\/|application\/(xhtml\+xml|xml|json|.*\+xml|.*\+json|javascript))/;
3706
- async function fetchUrl(url, options = {}) {
3707
- const method = options.method ?? "GET";
3708
- const redirect = options.redirect ?? "follow";
3709
- const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
3710
- const startedAt = Date.now();
3711
- const { signal, cleanup } = combineSignals(
3712
- options.signal,
3713
- options.timeoutMs ?? DEFAULT_TIMEOUT_MS
3714
- );
3715
- const restoreTls = relaxTlsIfRequested3(options.allowInsecureTls);
3716
- try {
3717
- const res = await fetch(url, {
3718
- method,
3719
- signal,
3720
- redirect,
3721
- headers: {
3722
- "user-agent": options.userAgent ?? DEFAULT_USER_AGENT,
3723
- accept: options.accept ?? DEFAULT_ACCEPT
3724
- }
3725
- });
3726
- const contentType = normaliseContentType(res.headers.get("content-type"));
3727
- const result = {
3728
- requestedUrl: url,
3729
- finalUrl: res.url || url,
3730
- status: res.status,
3731
- redirected: res.redirected,
3732
- redirectChain: [],
3733
- contentType,
3734
- durationMs: Date.now() - startedAt
3735
- };
3736
- const retryAfter = res.headers.get("retry-after");
3737
- if (retryAfter) result.retryAfter = retryAfter;
3738
- if (redirect === "manual" && isRedirectStatus(res.status)) {
3739
- const location = res.headers.get("location");
3740
- if (location) {
3741
- try {
3742
- const next = new URL(location, url).toString();
3743
- result.finalUrl = next;
3744
- result.redirectChain = [next];
3745
- result.redirected = true;
3746
- } catch {
3747
- }
3748
- }
3749
- await res.body?.cancel().catch(() => void 0);
3750
- return result;
3751
- }
3752
- if (method === "HEAD") {
3753
- await res.body?.cancel().catch(() => void 0);
3754
- return result;
3755
- }
3756
- if (contentType && !TEXTY.test(contentType)) {
3757
- await res.body?.cancel().catch(() => void 0);
3758
- return result;
3759
- }
3760
- const { text, truncated } = await readCappedText(res, maxBytes);
3761
- result.body = text;
3762
- if (truncated) result.truncated = true;
3763
- return result;
3764
- } catch (err) {
3765
- return {
3766
- requestedUrl: url,
3767
- finalUrl: url,
3768
- status: 0,
3769
- redirected: false,
3770
- redirectChain: [],
3771
- reason: classifyError(err, options.signal),
3772
- durationMs: Date.now() - startedAt
3773
- };
3774
- } finally {
3775
- cleanup();
3776
- restoreTls();
3777
- }
3778
- }
3779
- function isRedirectStatus(status) {
3780
- return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
3781
- }
3782
- function normaliseContentType(raw) {
3783
- if (!raw) return void 0;
3784
- const semi = raw.indexOf(";");
3785
- return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase() || void 0;
3786
- }
3787
- async function readCappedText(res, maxBytes) {
3788
- const reader = res.body?.getReader();
3789
- if (!reader) {
3790
- const text = await res.text();
3791
- if (text.length > maxBytes) {
3792
- return { text: text.slice(0, maxBytes), truncated: true };
3793
- }
3794
- return { text, truncated: false };
3795
- }
3796
- const chunks = [];
3797
- let received = 0;
3798
- let truncated = false;
3799
- for (; ; ) {
3800
- const { done, value } = await reader.read();
3801
- if (done) break;
3802
- if (!value) continue;
3803
- chunks.push(value);
3804
- received += value.byteLength;
3805
- if (received >= maxBytes) {
3806
- truncated = true;
3807
- await reader.cancel().catch(() => void 0);
3808
- break;
3809
- }
3810
- }
3811
- const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
3812
- const sliced = buf.byteLength > maxBytes ? buf.subarray(0, maxBytes) : buf;
3813
- return { text: sliced.toString("utf8"), truncated };
3814
- }
3815
- function classifyError(err, callerSignal) {
3816
- if (isAbortError(err)) {
3817
- return callerSignal?.aborted ? "abort" : "timeout";
3818
- }
3819
- const code = errorCode(err);
3820
- if (code) {
3821
- if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "dns";
3822
- if (code.startsWith("CERT_") || code.includes("SELF_SIGNED") || code.includes("CERT")) {
3823
- return "tls";
3824
- }
3825
- }
3826
- const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
3827
- if (message.includes("certificate") || message.includes("tls") || message.includes("ssl")) {
3828
- return "tls";
3829
- }
3830
- return "network";
3831
- }
3832
- function isAbortError(err) {
3833
- return err instanceof Error && err.name === "AbortError";
3834
- }
3835
- function errorCode(err) {
3836
- if (err && typeof err === "object") {
3837
- const direct = err.code;
3838
- if (typeof direct === "string") return direct;
3839
- const cause = err.cause;
3840
- if (cause && typeof cause === "object") {
3841
- const causeCode = cause.code;
3842
- if (typeof causeCode === "string") return causeCode;
3843
- }
3844
- }
3845
- return void 0;
3846
- }
3847
- function relaxTlsIfRequested3(enabled) {
3848
- if (!enabled) return () => void 0;
3849
- const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3850
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
3851
- return () => {
3852
- if (previous === void 0) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
3853
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous;
3854
- };
3855
- }
4754
+ category: "meta",
4755
+ title: "The meta description summarizes this page truthfully",
4756
+ why: "The description is the one sentence you get to write in a search result. A boilerplate line repeated site-wide, or one that promises something the page does not deliver, gets replaced by improvised page text \u2014 or gets the click and loses the visitor.",
4757
+ rigor: "guideline",
4758
+ sources: ["google-snippet", "moz-meta-description"],
4759
+ reads: ["meta.description", "document.title", "http.finalUrl"],
4760
+ relates: ["description.missing", "description.length"],
4761
+ appliesTo: (ex) => Boolean(ex.meta.description?.value?.trim()),
4762
+ prose: "Does the description accurately summarize this page's content, and is it written for this page rather than copied across the site?"
4763
+ };
4764
+ var langMatchesContent = {
4765
+ id: "lang.matches-content",
4766
+ kind: "prose",
4767
+ category: "i18n",
4768
+ title: "The declared `lang` is the language the page is actually written in",
4769
+ why: "A wrong `lang` is worse than a missing one: screen readers switch to the wrong pronunciation rules, browsers offer to translate a page that is already in the reader's language, and search engines file the page under the wrong audience. Nothing in the markup contradicts it, so no mechanical check can catch it.",
4770
+ rigor: "spec-required",
4771
+ sources: ["whatwg-html-lang", "w3c-i18n-language-tags"],
4772
+ reads: ["document.lang", "document.title", "meta.description", "openGraph.locale"],
4773
+ appliesTo: (ex) => Boolean(ex.document.lang?.value?.trim()),
4774
+ prose: "Is the text of this page actually written in the language its `lang` attribute declares?"
4775
+ };
4776
+ var ogImageRepresentative = {
4777
+ id: "og.image.representative",
4778
+ kind: "prose",
4779
+ category: "opengraph",
4780
+ title: "The `og:image` represents this page and survives the unfurl crop",
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.",
4782
+ rigor: "guideline",
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]));
3856
4796
 
3857
4797
  // src/lib/core/links/extract.ts
3858
4798
  import * as cheerio3 from "cheerio";
@@ -3937,188 +4877,6 @@ function resolveBase2($, pageUrl) {
3937
4877
  return pageUrl;
3938
4878
  }
3939
4879
 
3940
- // src/lib/core/links/classify.ts
3941
- function classifyLink(input) {
3942
- if (input.status === 0) return "broken";
3943
- if (input.loop) return "broken";
3944
- if (input.softNotFound) return "warning";
3945
- if (input.status === 403 || input.status === 429) return "blocked";
3946
- if (input.status >= 400) return "broken";
3947
- if (input.status >= 200 && input.status < 300) {
3948
- return input.redirected ? "redirect" : "ok";
3949
- }
3950
- return "broken";
3951
- }
3952
-
3953
- // src/lib/core/links/check.ts
3954
- var DEFAULTS2 = {
3955
- timeoutMs: 8e3,
3956
- maxRedirects: 10,
3957
- retries: 1,
3958
- backoffBaseMs: 300,
3959
- maxBackoffMs: 3e3,
3960
- softBodyBytes: 16 * 1024
3961
- };
3962
- var NOT_FOUND_RE = /\b(not\s+found|page\s+doesn'?t\s+exist|404|no\s+such\s+page)\b/i;
3963
- var SOFT_404_BODY_LIMIT = 2048;
3964
- async function checkLink(url, options = {}) {
3965
- const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
3966
- const started = Date.now();
3967
- const scheme = schemeOf(url);
3968
- if (scheme !== "http" && scheme !== "https") {
3969
- return {
3970
- url,
3971
- finalUrl: url,
3972
- status: 0,
3973
- verdict: "skipped",
3974
- method: "GET",
3975
- redirectChain: [],
3976
- reason: scheme ? `unsupported scheme: ${scheme}` : "not a URL",
3977
- checkedAt,
3978
- durationMs: Date.now() - started
3979
- };
3980
- }
3981
- const outcome = await probeWithRetry(url, options);
3982
- const softNotFound = isSoftNotFound(outcome);
3983
- const verdict = classifyLink({
3984
- status: outcome.status,
3985
- redirected: outcome.redirectChain.length > 0,
3986
- reason: outcome.reason,
3987
- softNotFound,
3988
- loop: outcome.loop
3989
- });
3990
- return {
3991
- url,
3992
- finalUrl: outcome.finalUrl,
3993
- status: outcome.status,
3994
- verdict,
3995
- method: outcome.method,
3996
- redirectChain: outcome.redirectChain,
3997
- reason: describeReason(outcome, softNotFound),
3998
- checkedAt,
3999
- durationMs: Date.now() - started
4000
- };
4001
- }
4002
- async function probeWithRetry(url, options) {
4003
- const retries = options.retries ?? DEFAULTS2.retries;
4004
- const sleep = options.sleep ?? defaultSleep;
4005
- let outcome = await probeWithHeadFallback(url, options);
4006
- for (let attempt = 0; attempt < retries; attempt++) {
4007
- if (!isTransient(outcome)) break;
4008
- if (options.signal?.aborted) break;
4009
- const delay = backoffDelay(outcome, attempt, options);
4010
- if (delay > 0) await sleep(delay);
4011
- outcome = await probeWithHeadFallback(url, options);
4012
- }
4013
- return outcome;
4014
- }
4015
- async function probeWithHeadFallback(url, options) {
4016
- const head = await followRedirects(url, "HEAD", options);
4017
- if (headRejected(head) || head.status >= 200 && head.status < 300) {
4018
- const get = await followRedirects(url, "GET", options);
4019
- if (get.status === 0 && head.status !== 0 && !headRejected(head)) return head;
4020
- return get;
4021
- }
4022
- return head;
4023
- }
4024
- function headRejected(o) {
4025
- return o.status === 405 || o.status === 501 || o.status === 403 || o.status === 0;
4026
- }
4027
- async function followRedirects(url, method, options) {
4028
- const maxRedirects = options.maxRedirects ?? DEFAULTS2.maxRedirects;
4029
- const chain = [];
4030
- const visited = /* @__PURE__ */ new Set([url]);
4031
- let current = url;
4032
- for (let hop = 0; hop <= maxRedirects; hop++) {
4033
- const res = await fetchUrl(current, {
4034
- method,
4035
- redirect: "manual",
4036
- timeoutMs: options.timeoutMs ?? DEFAULTS2.timeoutMs,
4037
- allowInsecureTls: options.allowInsecureTls,
4038
- userAgent: options.userAgent,
4039
- maxBytes: method === "GET" ? options.softBodyBytes ?? DEFAULTS2.softBodyBytes : void 0
4040
- });
4041
- if (res.status === 0) {
4042
- return {
4043
- status: 0,
4044
- finalUrl: current,
4045
- redirectChain: chain,
4046
- reason: res.reason,
4047
- loop: false,
4048
- method
4049
- };
4050
- }
4051
- const next = res.redirectChain[0];
4052
- if (next) {
4053
- chain.push(next);
4054
- if (visited.has(next)) {
4055
- return { status: res.status, finalUrl: next, redirectChain: chain, loop: true, method };
4056
- }
4057
- visited.add(next);
4058
- current = next;
4059
- continue;
4060
- }
4061
- return {
4062
- status: res.status,
4063
- finalUrl: res.finalUrl,
4064
- redirectChain: chain,
4065
- loop: false,
4066
- body: res.body,
4067
- retryAfter: res.retryAfter,
4068
- method
4069
- };
4070
- }
4071
- return { status: 0, finalUrl: current, redirectChain: chain, loop: true, method };
4072
- }
4073
- function isSoftNotFound(o) {
4074
- if (o.status < 200 || o.status >= 300) return false;
4075
- if (o.body === void 0) return false;
4076
- if (o.body.length > SOFT_404_BODY_LIMIT) return false;
4077
- return NOT_FOUND_RE.test(o.body);
4078
- }
4079
- function isTransient(o) {
4080
- if (o.status === 0) return true;
4081
- if (o.status === 429) return true;
4082
- if (o.status >= 500) return true;
4083
- return false;
4084
- }
4085
- function backoffDelay(o, attempt, options) {
4086
- const max = options.maxBackoffMs ?? DEFAULTS2.maxBackoffMs;
4087
- const retryAfterMs = parseRetryAfter(o.retryAfter);
4088
- if (retryAfterMs !== void 0) return Math.min(retryAfterMs, max);
4089
- const base = options.backoffBaseMs ?? DEFAULTS2.backoffBaseMs;
4090
- const exp = base * 2 ** attempt;
4091
- const jitter = Math.floor(Math.random() * base);
4092
- return Math.min(exp + jitter, max);
4093
- }
4094
- function parseRetryAfter(value) {
4095
- if (!value) return void 0;
4096
- const seconds = Number(value);
4097
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
4098
- const date = Date.parse(value);
4099
- if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
4100
- return void 0;
4101
- }
4102
- function describeReason(o, softNotFound) {
4103
- if (o.reason) return o.reason;
4104
- if (o.loop) return "redirect loop";
4105
- if (softNotFound) return "soft-404";
4106
- if (o.status === 429) return "429 rate-limited";
4107
- if (o.status === 403) return "403 forbidden";
4108
- if (o.status >= 400) return `${o.status}`;
4109
- return void 0;
4110
- }
4111
- function schemeOf(url) {
4112
- try {
4113
- return new URL(url).protocol.replace(/:$/, "").toLowerCase();
4114
- } catch {
4115
- return null;
4116
- }
4117
- }
4118
- function defaultSleep(ms) {
4119
- return new Promise((resolve) => setTimeout(resolve, ms));
4120
- }
4121
-
4122
4880
  // src/lib/core/links/types.ts
4123
4881
  function emptyVerdictSummary() {
4124
4882
  return { ok: 0, redirect: 0, broken: 0, blocked: 0, warning: 0, skipped: 0 };
@@ -4450,14 +5208,14 @@ async function attachManifests(pages, options) {
4450
5208
  }
4451
5209
  }
4452
5210
  async function probeAssets(pages, options) {
4453
- const sameOrigin = (url) => {
5211
+ const sameOrigin2 = (url) => {
4454
5212
  try {
4455
5213
  return new URL(url).origin === new URL(options.origin).origin;
4456
5214
  } catch {
4457
5215
  return false;
4458
5216
  }
4459
5217
  };
4460
- const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal || sameOrigin(url));
5218
+ const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal || sameOrigin2(url));
4461
5219
  const wanted = /* @__PURE__ */ new Set();
4462
5220
  for (const page of pages) {
4463
5221
  for (const image of page.openGraph.images) {
@@ -4630,7 +5388,14 @@ async function runAudit(inputUrl, options = {}) {
4630
5388
  severity: issue.severity,
4631
5389
  message: issue.message,
4632
5390
  why: rule?.title,
4633
- 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
4634
5399
  });
4635
5400
  }
4636
5401
  }
@@ -4677,6 +5442,33 @@ async function runAudit(inputUrl, options = {}) {
4677
5442
  ),
4678
5443
  ...issue
4679
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;
4680
5472
  const siteContext = {
4681
5473
  origin,
4682
5474
  pages: htmlPages,
@@ -4685,6 +5477,7 @@ async function runAudit(inputUrl, options = {}) {
4685
5477
  discovery,
4686
5478
  robots,
4687
5479
  favicon,
5480
+ sitemapEntries,
4688
5481
  // Same index the matrix was built from, so a rule that groups by route
4689
5482
  // and the grid that reports holes cannot disagree about which URLs are
4690
5483
  // one page.
@@ -4703,23 +5496,11 @@ async function runAudit(inputUrl, options = {}) {
4703
5496
  severity: draft.severity,
4704
5497
  message: draft.message,
4705
5498
  why: rule?.summary,
4706
- fix: draft.fix?.snippet
5499
+ fix: draft.fix?.snippet,
5500
+ rigor: draft.rigor,
5501
+ sources: draft.sources
4707
5502
  };
4708
5503
  });
4709
- const linkDiscovery = syntheticDiscovery(origin, entry, okPages, crawlResult.truncated);
4710
- const linkReport = await runLinkAudit(linkDiscovery, {
4711
- allowInsecureTls: options.allowInsecureTls,
4712
- checkExternal,
4713
- timeoutMs: options.timeoutMs,
4714
- maxPages: effectiveMaxPages,
4715
- signal: options.signal,
4716
- onProgress: (p) => options.onProgress?.({
4717
- phase: p.phase === "scan" ? "scan" : "links",
4718
- done: p.done,
4719
- total: p.total
4720
- })
4721
- });
4722
- warnings.push(...linkReport.diagnostics.warnings);
4723
5504
  const brokenLinks = [];
4724
5505
  for (const { pageUrl, broken } of linkReport.brokenByPage) {
4725
5506
  for (const check of broken) {
@@ -5209,7 +5990,8 @@ function rollupRows(issues, c) {
5209
5990
  const lines = [];
5210
5991
  for (const issue of issues) {
5211
5992
  const sev = issue.severity === "error" ? c(ANSI2.red, "error") : issue.severity === "warning" ? c(ANSI2.yellow, "warn ") : c(ANSI2.dim, "info ");
5212
- 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}`)}`);
5213
5995
  if (issue.why) lines.push(` ${c(ANSI2.dim, stripBackticks2(issue.why))}`);
5214
5996
  if (issue.fix) {
5215
5997
  const [head, ...rest] = issue.fix.split("\n");
@@ -5288,6 +6070,7 @@ function rollupByRule(issues) {
5288
6070
  groups.set(issue.ruleId, {
5289
6071
  ruleId: issue.ruleId,
5290
6072
  severity: issue.severity,
6073
+ rigor: issue.rigor,
5291
6074
  why: issue.why,
5292
6075
  fix: issue.fix,
5293
6076
  sample: issue.message,
@@ -5506,4 +6289,4 @@ export {
5506
6289
  summarize,
5507
6290
  Logger
5508
6291
  };
5509
- //# sourceMappingURL=chunk-TAZEAIFI.js.map
6292
+ //# sourceMappingURL=chunk-DKGBLJ4H.js.map