@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.
- package/README.md +39 -7
- package/dist/{chunk-TAZEAIFI.js → chunk-DKGBLJ4H.js} +1311 -528
- package/dist/chunk-DKGBLJ4H.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +37 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/rules.json +288 -3
- package/dist/chunk-TAZEAIFI.js.map +0 -1
|
@@ -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,
|
|
900
|
+
return { url, status: res.status, found: false, byteLength: 0, redirects, ...empty };
|
|
827
901
|
}
|
|
828
|
-
const
|
|
902
|
+
const raw = await res.text();
|
|
829
903
|
return {
|
|
830
904
|
url,
|
|
831
905
|
status: res.status,
|
|
832
906
|
found: true,
|
|
833
|
-
raw
|
|
834
|
-
|
|
835
|
-
|
|
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,
|
|
913
|
+
return { url, status: 0, found: false, byteLength: 0, redirects: noRedirect, ...empty };
|
|
839
914
|
} finally {
|
|
840
915
|
cleanup();
|
|
841
916
|
}
|
|
842
917
|
}
|
|
843
|
-
function
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
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
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
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
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
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
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
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
|
|
3267
|
+
return protocols;
|
|
2847
3268
|
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
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
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2866
|
-
const
|
|
2867
|
-
for (const
|
|
2868
|
-
const
|
|
2869
|
-
if (
|
|
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
|
-
|
|
2872
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
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
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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-
|
|
6292
|
+
//# sourceMappingURL=chunk-DKGBLJ4H.js.map
|