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