@goflag/cli 0.2.7 → 0.2.9
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 +34 -8
- package/dist/{chunk-UOKYUKHQ.js → chunk-TAZEAIFI.js} +977 -37
- package/dist/chunk-TAZEAIFI.js.map +1 -0
- package/dist/cli.js +547 -235
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +93 -3
- package/dist/index.js +1 -1
- package/flags.json +302 -0
- package/package.json +5 -2
- package/rules.json +366 -2
- package/dist/chunk-UOKYUKHQ.js.map +0 -1
|
@@ -1311,10 +1311,77 @@ var TITLE_IDEAL = [10, 60];
|
|
|
1311
1311
|
var TITLE_ACCEPTABLE = [5, 70];
|
|
1312
1312
|
var DESC_IDEAL = [50, 160];
|
|
1313
1313
|
var DESC_ACCEPTABLE = [25, 200];
|
|
1314
|
+
var RATIO_IDEAL = [1.7, 2.1];
|
|
1315
|
+
var RATIO_ACCEPTABLE = [1, 3];
|
|
1314
1316
|
function tokens(value) {
|
|
1315
1317
|
if (!value) return [];
|
|
1316
1318
|
return value.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
1317
1319
|
}
|
|
1320
|
+
function isAbsoluteHttpUrl(value) {
|
|
1321
|
+
try {
|
|
1322
|
+
const url = new URL(value);
|
|
1323
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1324
|
+
} catch {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function localeParts(tag) {
|
|
1329
|
+
const subtags = tag.trim().toLowerCase().split(/[-_]/).filter(Boolean);
|
|
1330
|
+
const language = subtags[0];
|
|
1331
|
+
if (!language || !/^[a-z]{2,3}$/.test(language)) return void 0;
|
|
1332
|
+
const region = subtags.slice(1).find((s) => /^([a-z]{2}|\d{3})$/.test(s));
|
|
1333
|
+
return region ? { language, region } : { language };
|
|
1334
|
+
}
|
|
1335
|
+
function sameLocale(a, b) {
|
|
1336
|
+
const left = localeParts(a);
|
|
1337
|
+
const right = localeParts(b);
|
|
1338
|
+
if (!left || !right || left.language !== right.language) return false;
|
|
1339
|
+
return !left.region || !right.region || left.region === right.region;
|
|
1340
|
+
}
|
|
1341
|
+
function hreflangCluster(ex) {
|
|
1342
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1343
|
+
for (const annotation of ex.links.hreflang) {
|
|
1344
|
+
if (annotation.isXDefault) continue;
|
|
1345
|
+
const parts = localeParts(annotation.hreflang);
|
|
1346
|
+
if (!parts) continue;
|
|
1347
|
+
seen.add(parts.region ? `${parts.language}-${parts.region}` : parts.language);
|
|
1348
|
+
}
|
|
1349
|
+
return [...seen];
|
|
1350
|
+
}
|
|
1351
|
+
function describeAsset(status, contentType) {
|
|
1352
|
+
if (status === 0) return "the request failed";
|
|
1353
|
+
if (status >= 400 || status < 200) return `HTTP ${status}`;
|
|
1354
|
+
return `HTTP ${status} with \`${contentType ?? "no content type"}\`, which is not an image`;
|
|
1355
|
+
}
|
|
1356
|
+
var ICON_RELS = /* @__PURE__ */ new Set([
|
|
1357
|
+
"icon",
|
|
1358
|
+
"shortcut icon",
|
|
1359
|
+
"apple-touch-icon",
|
|
1360
|
+
"apple-touch-icon-precomposed",
|
|
1361
|
+
"mask-icon"
|
|
1362
|
+
]);
|
|
1363
|
+
var APPLE_ICON_RELS = /* @__PURE__ */ new Set(["apple-touch-icon", "apple-touch-icon-precomposed"]);
|
|
1364
|
+
function declaredIcons(ex) {
|
|
1365
|
+
return ex.links.icons.filter((icon) => ICON_RELS.has(icon.rel.trim().toLowerCase()));
|
|
1366
|
+
}
|
|
1367
|
+
function normalise(value) {
|
|
1368
|
+
return value.trim().toLowerCase().split(/\s+/).sort().join(" ");
|
|
1369
|
+
}
|
|
1370
|
+
function sameAsset(href, src) {
|
|
1371
|
+
const pathOf = (value) => {
|
|
1372
|
+
try {
|
|
1373
|
+
return new URL(value, "https://goflag.invalid").pathname;
|
|
1374
|
+
} catch {
|
|
1375
|
+
return value.split("?")[0] ?? value;
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
return pathOf(href) === pathOf(src);
|
|
1379
|
+
}
|
|
1380
|
+
function hasOpenGraph(ex) {
|
|
1381
|
+
return Boolean(
|
|
1382
|
+
ex.openGraph.title?.value || ex.openGraph.images.length > 0 || ex.openGraph.url?.value
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1318
1385
|
var titleMissing = {
|
|
1319
1386
|
id: "title.missing",
|
|
1320
1387
|
kind: "boolean",
|
|
@@ -1569,10 +1636,7 @@ var ogDescriptionMissing = {
|
|
|
1569
1636
|
evaluate: (ex) => {
|
|
1570
1637
|
const observed = ex.openGraph.description?.value?.trim();
|
|
1571
1638
|
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 };
|
|
1639
|
+
if (!hasOpenGraph(ex)) return { status: "na", observed: null };
|
|
1576
1640
|
return {
|
|
1577
1641
|
status: "fail",
|
|
1578
1642
|
observed: null,
|
|
@@ -1616,6 +1680,476 @@ var ogImageMissing = {
|
|
|
1616
1680
|
};
|
|
1617
1681
|
}
|
|
1618
1682
|
};
|
|
1683
|
+
var ogImageAbsolute = {
|
|
1684
|
+
id: "og.image.absolute",
|
|
1685
|
+
kind: "boolean",
|
|
1686
|
+
category: "opengraph",
|
|
1687
|
+
// The same severity as `og.image.missing`, deliberately: the unfurl is
|
|
1688
|
+
// identical either way, and the registry should not rank the broken
|
|
1689
|
+
// declaration above the absent one when their effect is the same.
|
|
1690
|
+
severity: "warning",
|
|
1691
|
+
title: "`og:image` must be an absolute URL",
|
|
1692
|
+
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.",
|
|
1693
|
+
rigor: "vendor-spec",
|
|
1694
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
1695
|
+
reads: ["openGraph.images"],
|
|
1696
|
+
expected: "every `og:image` URL absolute, scheme included",
|
|
1697
|
+
relates: ["og.image.missing", "canonical.absolute"],
|
|
1698
|
+
fix: {
|
|
1699
|
+
title: "Give Next a metadataBase, or write the origin in",
|
|
1700
|
+
snippet: [
|
|
1701
|
+
"// app/layout.tsx \u2014 every relative metadata URL resolves against this.",
|
|
1702
|
+
"export const metadata = {",
|
|
1703
|
+
" metadataBase: new URL(process.env.SITE_ORIGIN!),",
|
|
1704
|
+
' openGraph: { images: ["/og.png"] }, // emitted absolute',
|
|
1705
|
+
"};"
|
|
1706
|
+
].join("\n"),
|
|
1707
|
+
language: "tsx"
|
|
1708
|
+
},
|
|
1709
|
+
evaluate: (ex) => {
|
|
1710
|
+
if (ex.openGraph.images.length === 0) return { status: "na", observed: null };
|
|
1711
|
+
const declarations = [];
|
|
1712
|
+
for (const image of ex.openGraph.images) {
|
|
1713
|
+
declarations.push({ property: "og:image", value: image.url.value.trim() });
|
|
1714
|
+
if (image.secureUrl) {
|
|
1715
|
+
declarations.push({
|
|
1716
|
+
property: "og:image:secure_url",
|
|
1717
|
+
value: image.secureUrl.value.trim()
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
const observed = declarations.map((d) => d.value);
|
|
1722
|
+
const relative = declarations.filter((d) => !isAbsoluteHttpUrl(d.value));
|
|
1723
|
+
if (relative.length === 0) return { status: "pass", observed };
|
|
1724
|
+
return {
|
|
1725
|
+
status: "fail",
|
|
1726
|
+
observed,
|
|
1727
|
+
message: `Open Graph image URL is not absolute: ${relative.map((d) => `\`${d.property}\` = \`${d.value}\``).join(", ")}. Crawlers cannot resolve it.`,
|
|
1728
|
+
origin: { kind: "meta", property: relative[0].property }
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
var ogImageAlt = {
|
|
1733
|
+
id: "og.image.alt",
|
|
1734
|
+
kind: "boolean",
|
|
1735
|
+
category: "opengraph",
|
|
1736
|
+
severity: "warning",
|
|
1737
|
+
title: "Describe the shared image with `og:image:alt`",
|
|
1738
|
+
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.",
|
|
1739
|
+
rigor: "guideline",
|
|
1740
|
+
sources: ["ogp"],
|
|
1741
|
+
reads: ["openGraph.images"],
|
|
1742
|
+
expected: "an `og:image:alt` beside every `og:image`",
|
|
1743
|
+
relates: ["og.image.missing"],
|
|
1744
|
+
fix: {
|
|
1745
|
+
title: "Carry the alt per image, not as a constant",
|
|
1746
|
+
snippet: [
|
|
1747
|
+
"// app/\u2026/opengraph-image.tsx \u2014 `export const alt` is one fixed string, so a",
|
|
1748
|
+
"// translated or data-derived description has to go through the metadata fn.",
|
|
1749
|
+
"export async function generateImageMetadata({ params }) {",
|
|
1750
|
+
" const t = await getTranslations({ locale: params.locale });",
|
|
1751
|
+
' return [{ id: "og", size, contentType, alt: t("og.alt") }];',
|
|
1752
|
+
"}"
|
|
1753
|
+
].join("\n"),
|
|
1754
|
+
language: "tsx"
|
|
1755
|
+
},
|
|
1756
|
+
evaluate: (ex) => {
|
|
1757
|
+
if (ex.openGraph.images.length === 0) return { status: "na", observed: null };
|
|
1758
|
+
const observed = ex.openGraph.images.map((image) => ({
|
|
1759
|
+
url: image.url.value,
|
|
1760
|
+
alt: image.alt?.value ?? null
|
|
1761
|
+
}));
|
|
1762
|
+
const undescribed = observed.filter((image) => !image.alt?.trim());
|
|
1763
|
+
if (undescribed.length === 0) return { status: "pass", observed };
|
|
1764
|
+
return {
|
|
1765
|
+
status: "fail",
|
|
1766
|
+
observed,
|
|
1767
|
+
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\`.`,
|
|
1768
|
+
origin: { kind: "meta", property: "og:image:alt" }
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
var ogImageDimensions = {
|
|
1773
|
+
id: "og.image.dimensions",
|
|
1774
|
+
kind: "boolean",
|
|
1775
|
+
category: "opengraph",
|
|
1776
|
+
severity: "warning",
|
|
1777
|
+
title: "Declare `og:image:width` and `og:image:height`",
|
|
1778
|
+
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.",
|
|
1779
|
+
rigor: "vendor-spec",
|
|
1780
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
1781
|
+
reads: ["openGraph.images"],
|
|
1782
|
+
expected: "both `og:image:width` and `og:image:height` on every image",
|
|
1783
|
+
relates: ["og.image.missing", "og.image.ratio"],
|
|
1784
|
+
fix: {
|
|
1785
|
+
title: "Declare the size alongside the URL",
|
|
1786
|
+
snippet: [
|
|
1787
|
+
"// app/\u2026/page.tsx",
|
|
1788
|
+
"export const metadata = {",
|
|
1789
|
+
' openGraph: { images: [{ url: "/og.png", width: 1200, height: 630 }] },',
|
|
1790
|
+
"};",
|
|
1791
|
+
"// The file convention emits both from `export const size` for free."
|
|
1792
|
+
].join("\n"),
|
|
1793
|
+
language: "tsx"
|
|
1794
|
+
},
|
|
1795
|
+
evaluate: (ex) => {
|
|
1796
|
+
if (ex.openGraph.images.length === 0) return { status: "na", observed: null };
|
|
1797
|
+
const observed = ex.openGraph.images.map((image) => ({
|
|
1798
|
+
url: image.url.value,
|
|
1799
|
+
width: image.width?.value ?? null,
|
|
1800
|
+
height: image.height?.value ?? null
|
|
1801
|
+
}));
|
|
1802
|
+
const incomplete = observed.filter((image) => image.width === null || image.height === null);
|
|
1803
|
+
if (incomplete.length === 0) return { status: "pass", observed };
|
|
1804
|
+
return {
|
|
1805
|
+
status: "fail",
|
|
1806
|
+
observed,
|
|
1807
|
+
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\`.`,
|
|
1808
|
+
origin: { kind: "meta", property: "og:image:width" }
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
var ogImageRatio = {
|
|
1813
|
+
id: "og.image.ratio",
|
|
1814
|
+
kind: "scored",
|
|
1815
|
+
category: "opengraph",
|
|
1816
|
+
title: "Keep the shared image near the 1.91:1 the card is laid out for",
|
|
1817
|
+
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.",
|
|
1818
|
+
rigor: "vendor-spec",
|
|
1819
|
+
sources: ["meta-og-sharing", "x-cards"],
|
|
1820
|
+
reads: ["openGraph.images"],
|
|
1821
|
+
bands: { ideal: RATIO_IDEAL, acceptable: RATIO_ACCEPTABLE },
|
|
1822
|
+
severityByBand: { acceptable: "info", poor: "warning" },
|
|
1823
|
+
expected: `a width \xF7 height between ${RATIO_IDEAL[0]} and ${RATIO_IDEAL[1]} (1.91:1 is 1.9)`,
|
|
1824
|
+
relates: ["og.image.dimensions"],
|
|
1825
|
+
evaluate: (ex) => {
|
|
1826
|
+
const measured = ex.openGraph.images.find((image) => image.width?.value && image.height?.value);
|
|
1827
|
+
if (!measured) return { status: "na", observed: 0 };
|
|
1828
|
+
const ratio = Math.round(measured.width.value / measured.height.value * 100) / 100;
|
|
1829
|
+
const { status, band } = bandFor(ratio, { ideal: RATIO_IDEAL, acceptable: RATIO_ACCEPTABLE });
|
|
1830
|
+
if (status === "pass") return { status, band, observed: ratio };
|
|
1831
|
+
return {
|
|
1832
|
+
status,
|
|
1833
|
+
band,
|
|
1834
|
+
observed: ratio,
|
|
1835
|
+
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.`,
|
|
1836
|
+
origin: { kind: "meta", property: "og:image:width" }
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
};
|
|
1840
|
+
var ogLocaleMissing = {
|
|
1841
|
+
id: "og.locale.missing",
|
|
1842
|
+
kind: "boolean",
|
|
1843
|
+
category: "opengraph",
|
|
1844
|
+
severity: "warning",
|
|
1845
|
+
title: "A translated page has to say which locale it is in",
|
|
1846
|
+
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.",
|
|
1847
|
+
rigor: "vendor-spec",
|
|
1848
|
+
sources: ["ogp"],
|
|
1849
|
+
reads: ["openGraph.locale", "links.hreflang"],
|
|
1850
|
+
expected: "an `og:locale` on any page that declares hreflang alternates",
|
|
1851
|
+
relates: ["og.locale.alternates", "hreflang.missing"],
|
|
1852
|
+
fix: {
|
|
1853
|
+
title: "Emit the locale in the page's own metadata",
|
|
1854
|
+
snippet: [
|
|
1855
|
+
"// app/[locale]/\u2026/page.tsx",
|
|
1856
|
+
"export const metadata = {",
|
|
1857
|
+
' openGraph: { locale: "fr_FR", alternateLocale: ["en_US", "es_ES"] },',
|
|
1858
|
+
"};"
|
|
1859
|
+
].join("\n"),
|
|
1860
|
+
language: "tsx"
|
|
1861
|
+
},
|
|
1862
|
+
evaluate: (ex) => {
|
|
1863
|
+
const cluster = hreflangCluster(ex);
|
|
1864
|
+
const observed = ex.openGraph.locale?.value?.trim() ?? null;
|
|
1865
|
+
if (cluster.length < 2 || !hasOpenGraph(ex)) return { status: "na", observed };
|
|
1866
|
+
if (observed) return { status: "pass", observed };
|
|
1867
|
+
return {
|
|
1868
|
+
status: "fail",
|
|
1869
|
+
observed: null,
|
|
1870
|
+
message: `Page declares hreflang alternates (${cluster.join(", ")}) but no \`og:locale\`; the protocol default \`en_US\` applies instead.`,
|
|
1871
|
+
origin: { kind: "meta", property: "og:locale" }
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
var ogLocaleAlternates = {
|
|
1876
|
+
id: "og.locale.alternates",
|
|
1877
|
+
kind: "boolean",
|
|
1878
|
+
category: "opengraph",
|
|
1879
|
+
severity: "warning",
|
|
1880
|
+
title: "`og:locale:alternate` and the hreflang cluster must name the same locales",
|
|
1881
|
+
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.",
|
|
1882
|
+
rigor: "vendor-spec",
|
|
1883
|
+
sources: ["ogp", "google-hreflang"],
|
|
1884
|
+
reads: ["openGraph.locale", "openGraph.localeAlternates", "links.hreflang"],
|
|
1885
|
+
expected: "one `og:locale:alternate` per hreflang locale other than this page's own",
|
|
1886
|
+
relates: ["og.locale.missing", "hreflang.missing"],
|
|
1887
|
+
fix: {
|
|
1888
|
+
title: "Derive both lists from the same cluster",
|
|
1889
|
+
snippet: [
|
|
1890
|
+
"// The hreflang map and the OG locales are one fact stated twice.",
|
|
1891
|
+
"openGraph: {",
|
|
1892
|
+
" locale: ogLocale(locale),",
|
|
1893
|
+
" alternateLocale: cluster.filter((l) => l !== locale).map(ogLocale),",
|
|
1894
|
+
"},",
|
|
1895
|
+
"alternates: { languages: cluster },"
|
|
1896
|
+
].join("\n"),
|
|
1897
|
+
language: "ts"
|
|
1898
|
+
},
|
|
1899
|
+
evaluate: (ex) => {
|
|
1900
|
+
const cluster = hreflangCluster(ex);
|
|
1901
|
+
const own = ex.openGraph.locale?.value?.trim();
|
|
1902
|
+
const declared = ex.openGraph.localeAlternates.map((a) => a.value.trim()).filter(Boolean);
|
|
1903
|
+
const observed = { locale: own ?? null, alternates: declared, hreflang: cluster };
|
|
1904
|
+
if (cluster.length < 2 || !own) return { status: "na", observed };
|
|
1905
|
+
const missing = cluster.filter(
|
|
1906
|
+
(tag) => !sameLocale(tag, own) && !declared.some((d) => sameLocale(d, tag))
|
|
1907
|
+
);
|
|
1908
|
+
const unbacked = declared.filter((d) => !cluster.some((tag) => sameLocale(tag, d)));
|
|
1909
|
+
if (missing.length === 0 && unbacked.length === 0) return { status: "pass", observed };
|
|
1910
|
+
const disagreements = [];
|
|
1911
|
+
if (missing.length > 0) {
|
|
1912
|
+
disagreements.push(`no \`og:locale:alternate\` for ${missing.join(", ")}`);
|
|
1913
|
+
}
|
|
1914
|
+
if (unbacked.length > 0) {
|
|
1915
|
+
disagreements.push(`\`og:locale:alternate\` ${unbacked.join(", ")} has no hreflang`);
|
|
1916
|
+
}
|
|
1917
|
+
return {
|
|
1918
|
+
status: "fail",
|
|
1919
|
+
observed,
|
|
1920
|
+
message: `Open Graph and hreflang disagree about this page's translations: ${disagreements.join("; ")}.`,
|
|
1921
|
+
origin: { kind: "meta", property: "og:locale:alternate" }
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
var iconsMissing = {
|
|
1926
|
+
id: "icons.missing",
|
|
1927
|
+
kind: "boolean",
|
|
1928
|
+
category: "icons",
|
|
1929
|
+
severity: "warning",
|
|
1930
|
+
title: "Declare an icon \u2014 the tab, the bookmark and the share sheet all read one",
|
|
1931
|
+
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.",
|
|
1932
|
+
rigor: "guideline",
|
|
1933
|
+
sources: ["whatwg-html-link-types", "mdn-link-rel"],
|
|
1934
|
+
reads: ["links.icons", "links.manifest"],
|
|
1935
|
+
expected: 'at least one `<link rel="icon">`, or icons declared by a manifest',
|
|
1936
|
+
relates: ["icons.apple-touch.missing", "icons.manifest-mismatch"],
|
|
1937
|
+
fix: {
|
|
1938
|
+
title: "Let the file convention declare it",
|
|
1939
|
+
snippet: [
|
|
1940
|
+
'// app/icon.svg (or icon.png) \u2014 Next emits the <link rel="icon"> itself.',
|
|
1941
|
+
'// app/apple-icon.png likewise emits rel="apple-touch-icon".',
|
|
1942
|
+
"// A /favicon.ico at the root is a separate matter: see icons.ico.missing."
|
|
1943
|
+
].join("\n"),
|
|
1944
|
+
language: "tsx"
|
|
1945
|
+
},
|
|
1946
|
+
evaluate: (ex) => {
|
|
1947
|
+
const head = declaredIcons(ex);
|
|
1948
|
+
const fromManifest = ex.links.manifest?.icons ?? [];
|
|
1949
|
+
const observed = {
|
|
1950
|
+
head: head.map((icon) => ({ rel: icon.rel, href: icon.href })),
|
|
1951
|
+
manifest: fromManifest.map((icon) => icon.src)
|
|
1952
|
+
};
|
|
1953
|
+
if (head.length > 0 || fromManifest.length > 0) return { status: "pass", observed };
|
|
1954
|
+
return {
|
|
1955
|
+
status: "fail",
|
|
1956
|
+
observed,
|
|
1957
|
+
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.',
|
|
1958
|
+
origin: { kind: "link", rel: "icon" }
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
var iconsAppleTouchMissing = {
|
|
1963
|
+
id: "icons.apple-touch.missing",
|
|
1964
|
+
kind: "boolean",
|
|
1965
|
+
category: "icons",
|
|
1966
|
+
severity: "info",
|
|
1967
|
+
title: "Declare an `apple-touch-icon` for the iOS home screen",
|
|
1968
|
+
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.',
|
|
1969
|
+
rigor: "vendor-spec",
|
|
1970
|
+
sources: ["apple-web-apps", "whatwg-html-link-types"],
|
|
1971
|
+
reads: ["links.icons", "links.manifest"],
|
|
1972
|
+
expected: 'a `<link rel="apple-touch-icon">`',
|
|
1973
|
+
relates: ["icons.missing"],
|
|
1974
|
+
evaluate: (ex) => {
|
|
1975
|
+
const head = declaredIcons(ex);
|
|
1976
|
+
if (head.length === 0) return { status: "na", observed: null };
|
|
1977
|
+
const apple = head.filter((icon) => APPLE_ICON_RELS.has(icon.rel.trim().toLowerCase()));
|
|
1978
|
+
const observed = head.map((icon) => icon.rel);
|
|
1979
|
+
if (apple.length > 0) return { status: "pass", observed };
|
|
1980
|
+
return {
|
|
1981
|
+
status: "fail",
|
|
1982
|
+
observed,
|
|
1983
|
+
message: `Page declares icons (${observed.join(", ")}) but no \`apple-touch-icon\`; iOS will screenshot the page instead.`,
|
|
1984
|
+
origin: { kind: "link", rel: "apple-touch-icon" }
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
};
|
|
1988
|
+
var iconsManifestMismatch = {
|
|
1989
|
+
id: "icons.manifest-mismatch",
|
|
1990
|
+
kind: "boolean",
|
|
1991
|
+
category: "icons",
|
|
1992
|
+
severity: "info",
|
|
1993
|
+
title: "The manifest and the `<head>` must not disagree about the same icon",
|
|
1994
|
+
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.",
|
|
1995
|
+
rigor: "guideline",
|
|
1996
|
+
sources: ["w3c-appmanifest", "whatwg-html-link-types"],
|
|
1997
|
+
reads: ["links.icons", "links.manifest"],
|
|
1998
|
+
expected: "the same size and type wherever an icon is declared twice",
|
|
1999
|
+
relates: ["icons.missing"],
|
|
2000
|
+
evaluate: (ex) => {
|
|
2001
|
+
const manifest = ex.links.manifest;
|
|
2002
|
+
if (!manifest?.parsed || !manifest.icons || manifest.icons.length === 0) {
|
|
2003
|
+
return { status: "na", observed: null };
|
|
2004
|
+
}
|
|
2005
|
+
const head = declaredIcons(ex);
|
|
2006
|
+
const observed = {
|
|
2007
|
+
head: head.map((icon) => ({ href: icon.href, sizes: icon.sizes ?? null })),
|
|
2008
|
+
manifest: manifest.icons.map((icon) => ({ src: icon.src, sizes: icon.sizes ?? null }))
|
|
2009
|
+
};
|
|
2010
|
+
if (head.length === 0) {
|
|
2011
|
+
return {
|
|
2012
|
+
status: "fail",
|
|
2013
|
+
observed,
|
|
2014
|
+
message: `Manifest declares ${manifest.icons.length} icon(s) but the \`<head>\` declares none; a browser tab reads the \`<head>\`, not the manifest.`,
|
|
2015
|
+
origin: { kind: "link", rel: "manifest" }
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
const disagreements = [];
|
|
2019
|
+
for (const icon of manifest.icons) {
|
|
2020
|
+
const twin = head.find((candidate) => sameAsset(candidate.href, icon.src));
|
|
2021
|
+
if (!twin) continue;
|
|
2022
|
+
if (icon.sizes && twin.sizes && normalise(icon.sizes) !== normalise(twin.sizes)) {
|
|
2023
|
+
disagreements.push(
|
|
2024
|
+
`\`${icon.src}\` is \`${twin.sizes}\` in the \`<head>\` and \`${icon.sizes}\` in the manifest`
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
if (icon.type && twin.type && icon.type.toLowerCase() !== twin.type.toLowerCase()) {
|
|
2028
|
+
disagreements.push(
|
|
2029
|
+
`\`${icon.src}\` is \`${twin.type}\` in the \`<head>\` and \`${icon.type}\` in the manifest`
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (disagreements.length === 0) return { status: "pass", observed };
|
|
2034
|
+
return {
|
|
2035
|
+
status: "fail",
|
|
2036
|
+
observed,
|
|
2037
|
+
message: `The manifest and the \`<head>\` describe the same icon differently: ${disagreements.join("; ")}.`,
|
|
2038
|
+
origin: { kind: "link", rel: "manifest" }
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
var ogImageReachable = {
|
|
2043
|
+
id: "og.image.reachable",
|
|
2044
|
+
kind: "boolean",
|
|
2045
|
+
category: "opengraph",
|
|
2046
|
+
severity: "error",
|
|
2047
|
+
title: "The URL in `og:image` has to answer with an image",
|
|
2048
|
+
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.",
|
|
2049
|
+
rigor: "vendor-spec",
|
|
2050
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
2051
|
+
reads: ["openGraph.images", "assets"],
|
|
2052
|
+
expected: "a 2xx image at every `og:image` URL",
|
|
2053
|
+
relates: ["og.image.missing", "og.image.absolute"],
|
|
2054
|
+
evaluate: (ex) => {
|
|
2055
|
+
if (!ex.assets) return { status: "na", observed: null };
|
|
2056
|
+
const probed = ex.openGraph.images.map((image) => ex.assets?.[image.url.value.trim()]).filter((probe) => probe !== void 0);
|
|
2057
|
+
if (probed.length === 0) return { status: "na", observed: null };
|
|
2058
|
+
const observed = probed.map((probe) => ({
|
|
2059
|
+
status: probe.status,
|
|
2060
|
+
contentType: probe.contentType ?? null
|
|
2061
|
+
}));
|
|
2062
|
+
const dead = probed.filter((probe) => !probe.ok);
|
|
2063
|
+
if (dead.length === 0) return { status: "pass", observed };
|
|
2064
|
+
return {
|
|
2065
|
+
status: "fail",
|
|
2066
|
+
observed,
|
|
2067
|
+
message: `\`og:image\` does not serve an image: ${dead.map((probe) => describeAsset(probe.status, probe.contentType)).join("; ")}. The preview card will render without it.`,
|
|
2068
|
+
origin: { kind: "meta", property: "og:image" }
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
var iconsUnreachable = {
|
|
2073
|
+
id: "icons.unreachable",
|
|
2074
|
+
kind: "boolean",
|
|
2075
|
+
category: "icons",
|
|
2076
|
+
severity: "warning",
|
|
2077
|
+
title: "A declared icon has to answer with an image",
|
|
2078
|
+
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.",
|
|
2079
|
+
rigor: "vendor-spec",
|
|
2080
|
+
sources: ["whatwg-html-link-types", "mdn-link-rel"],
|
|
2081
|
+
reads: ["links.icons", "assets"],
|
|
2082
|
+
expected: "a 2xx image at every declared icon URL",
|
|
2083
|
+
relates: ["icons.missing", "icons.ico.missing"],
|
|
2084
|
+
evaluate: (ex) => {
|
|
2085
|
+
if (!ex.assets) return { status: "na", observed: null };
|
|
2086
|
+
const probed = declaredIcons(ex).map((icon) => ({ icon, probe: ex.assets?.[icon.href] })).filter(
|
|
2087
|
+
(pair) => Boolean(pair.probe)
|
|
2088
|
+
);
|
|
2089
|
+
if (probed.length === 0) return { status: "na", observed: null };
|
|
2090
|
+
const observed = probed.map(({ icon, probe }) => ({
|
|
2091
|
+
rel: icon.rel,
|
|
2092
|
+
href: icon.href,
|
|
2093
|
+
status: probe.status
|
|
2094
|
+
}));
|
|
2095
|
+
const dead = probed.filter(({ probe }) => !probe.ok);
|
|
2096
|
+
if (dead.length === 0) return { status: "pass", observed };
|
|
2097
|
+
return {
|
|
2098
|
+
status: "fail",
|
|
2099
|
+
observed,
|
|
2100
|
+
message: `Declared icon does not serve an image: ${dead.map(
|
|
2101
|
+
({ icon, probe }) => `\`${icon.rel}\` \u2192 \`${icon.href}\` (${describeAsset(probe.status, probe.contentType)})`
|
|
2102
|
+
).join("; ")}.`,
|
|
2103
|
+
origin: { kind: "link", rel: "icon" }
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
var iconsSizesMismatch = {
|
|
2108
|
+
id: "icons.sizes-mismatch",
|
|
2109
|
+
kind: "boolean",
|
|
2110
|
+
category: "icons",
|
|
2111
|
+
severity: "info",
|
|
2112
|
+
title: "A `sizes` attribute must describe the file it points at",
|
|
2113
|
+
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.",
|
|
2114
|
+
rigor: "guideline",
|
|
2115
|
+
sources: ["whatwg-html-link-types", "mdn-link-rel"],
|
|
2116
|
+
reads: ["links.icons", "assets"],
|
|
2117
|
+
expected: "`sizes` listing what the file actually contains",
|
|
2118
|
+
relates: ["icons.unreachable"],
|
|
2119
|
+
evaluate: (ex) => {
|
|
2120
|
+
if (!ex.assets) return { status: "na", observed: null };
|
|
2121
|
+
const comparable = declaredIcons(ex).map((icon) => ({ icon, probe: ex.assets?.[icon.href] })).filter(
|
|
2122
|
+
(pair) => (
|
|
2123
|
+
// Only where both sides said something. `any` declares no dimension
|
|
2124
|
+
// to check, and a format goflag does not decode leaves `sizes`
|
|
2125
|
+
// absent — which means unknown, and unknown is not a mismatch.
|
|
2126
|
+
Boolean(pair.probe?.ok && pair.probe.sizes?.length) && pair.icon.parsedSizes.some((size) => size !== "any")
|
|
2127
|
+
)
|
|
2128
|
+
);
|
|
2129
|
+
if (comparable.length === 0) return { status: "na", observed: null };
|
|
2130
|
+
const observed = comparable.map(({ icon, probe }) => ({
|
|
2131
|
+
href: icon.href,
|
|
2132
|
+
declared: icon.sizes ?? null,
|
|
2133
|
+
actual: (probe.sizes ?? []).map((size) => `${size.width}x${size.height}`)
|
|
2134
|
+
}));
|
|
2135
|
+
const wrong = observed.filter((entry) => {
|
|
2136
|
+
const declared = new Set(
|
|
2137
|
+
comparable.find((pair) => pair.icon.href === entry.href).icon.parsedSizes.filter((size) => size !== "any").map((size) => `${size.width}x${size.height}`)
|
|
2138
|
+
);
|
|
2139
|
+
const actual = new Set(entry.actual);
|
|
2140
|
+
return declared.size !== actual.size || [...declared].some((size) => !actual.has(size));
|
|
2141
|
+
});
|
|
2142
|
+
if (wrong.length === 0) return { status: "pass", observed };
|
|
2143
|
+
return {
|
|
2144
|
+
status: "fail",
|
|
2145
|
+
observed,
|
|
2146
|
+
message: `\`sizes\` does not describe the file: ${wrong.map(
|
|
2147
|
+
(entry) => `\`${entry.href}\` declares \`${entry.declared}\` and contains ${entry.actual.join(", ")}`
|
|
2148
|
+
).join("; ")}.`,
|
|
2149
|
+
origin: { kind: "link", rel: "icon" }
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
};
|
|
1619
2153
|
var robotsConflict = {
|
|
1620
2154
|
id: "robots.conflict",
|
|
1621
2155
|
kind: "boolean",
|
|
@@ -1677,8 +2211,20 @@ var RULES = [
|
|
|
1677
2211
|
canonicalMissing,
|
|
1678
2212
|
descriptionLength,
|
|
1679
2213
|
descriptionMissing,
|
|
2214
|
+
iconsAppleTouchMissing,
|
|
2215
|
+
iconsManifestMismatch,
|
|
2216
|
+
iconsMissing,
|
|
2217
|
+
iconsSizesMismatch,
|
|
2218
|
+
iconsUnreachable,
|
|
1680
2219
|
ogDescriptionMissing,
|
|
2220
|
+
ogImageAbsolute,
|
|
2221
|
+
ogImageAlt,
|
|
2222
|
+
ogImageDimensions,
|
|
1681
2223
|
ogImageMissing,
|
|
2224
|
+
ogImageRatio,
|
|
2225
|
+
ogImageReachable,
|
|
2226
|
+
ogLocaleAlternates,
|
|
2227
|
+
ogLocaleMissing,
|
|
1682
2228
|
ogTitleMissing,
|
|
1683
2229
|
robotsConflict,
|
|
1684
2230
|
titleLength,
|
|
@@ -1720,11 +2266,44 @@ function openGraphFromPage(page) {
|
|
|
1720
2266
|
other: og.unknown
|
|
1721
2267
|
};
|
|
1722
2268
|
}
|
|
2269
|
+
function optionalString(value) {
|
|
2270
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
2271
|
+
}
|
|
2272
|
+
function manifestIcons(data) {
|
|
2273
|
+
if (typeof data !== "object" || data === null) return [];
|
|
2274
|
+
const raw = data.icons;
|
|
2275
|
+
if (!Array.isArray(raw)) return [];
|
|
2276
|
+
const icons = [];
|
|
2277
|
+
for (const entry of raw) {
|
|
2278
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
2279
|
+
const { src, sizes, type, purpose } = entry;
|
|
2280
|
+
const source = optionalString(src);
|
|
2281
|
+
if (!source) continue;
|
|
2282
|
+
icons.push({
|
|
2283
|
+
src: source,
|
|
2284
|
+
sizes: optionalString(sizes),
|
|
2285
|
+
type: optionalString(type),
|
|
2286
|
+
purpose: optionalString(purpose),
|
|
2287
|
+
parsedSizes: parseSizes(optionalString(sizes))
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
return icons;
|
|
2291
|
+
}
|
|
2292
|
+
function manifestFromPage(page) {
|
|
2293
|
+
const link = page.links.manifest;
|
|
2294
|
+
if (!link) return void 0;
|
|
2295
|
+
const probe = page.probes.manifest;
|
|
2296
|
+
if (!probe) return { href: link.href };
|
|
2297
|
+
if (!probe.found || probe.parseError !== void 0) {
|
|
2298
|
+
return { href: link.href, parsed: false };
|
|
2299
|
+
}
|
|
2300
|
+
return { href: link.href, parsed: true, icons: manifestIcons(probe.data) };
|
|
2301
|
+
}
|
|
1723
2302
|
function linksFromPage(page) {
|
|
1724
2303
|
return {
|
|
1725
2304
|
hreflang: page.links.alternates,
|
|
1726
2305
|
icons: page.links.icons,
|
|
1727
|
-
manifest: page
|
|
2306
|
+
manifest: manifestFromPage(page),
|
|
1728
2307
|
feeds: page.links.feeds.map(({ type, href, title }) => ({ type, href, title }))
|
|
1729
2308
|
};
|
|
1730
2309
|
}
|
|
@@ -1761,6 +2340,7 @@ function extractionFromPage(page) {
|
|
|
1761
2340
|
keywords: page.meta.keywords
|
|
1762
2341
|
},
|
|
1763
2342
|
openGraph: openGraphFromPage(page),
|
|
2343
|
+
...page.assets ? { assets: page.assets } : {},
|
|
1764
2344
|
twitter: {
|
|
1765
2345
|
card: page.twitter.card,
|
|
1766
2346
|
site: page.twitter.site,
|
|
@@ -1856,13 +2436,18 @@ function buildI18nMatrix(pages, options = {}) {
|
|
|
1856
2436
|
const grid = /* @__PURE__ */ new Map();
|
|
1857
2437
|
const locales = /* @__PURE__ */ new Set();
|
|
1858
2438
|
const routes = /* @__PURE__ */ new Set();
|
|
2439
|
+
const declaredSet = new Set(options.declaredUrls ?? []);
|
|
1859
2440
|
for (const locale of options.locales ?? []) {
|
|
1860
2441
|
const tag = locale.trim().toLowerCase();
|
|
1861
2442
|
if (tag) locales.add(tag);
|
|
1862
2443
|
}
|
|
1863
|
-
function
|
|
2444
|
+
function rowOf2(url, pathnameRoute) {
|
|
1864
2445
|
return options.clusterRouteOf?.(url) ?? pathnameRoute;
|
|
1865
2446
|
}
|
|
2447
|
+
function sourceOf(url) {
|
|
2448
|
+
if (inspectedByUrl.has(url)) return "crawled";
|
|
2449
|
+
return declaredSet.has(url) ? "sitemap" : "alternate";
|
|
2450
|
+
}
|
|
1866
2451
|
function record(route, locale, url) {
|
|
1867
2452
|
routes.add(route);
|
|
1868
2453
|
locales.add(locale);
|
|
@@ -1872,13 +2457,13 @@ function buildI18nMatrix(pages, options = {}) {
|
|
|
1872
2457
|
grid.set(route, row);
|
|
1873
2458
|
}
|
|
1874
2459
|
if (!row.has(locale)) {
|
|
1875
|
-
row.set(locale, { url, inspected: inspectedByUrl.has(url) });
|
|
2460
|
+
row.set(locale, { url, inspected: inspectedByUrl.has(url), source: sourceOf(url) });
|
|
1876
2461
|
}
|
|
1877
2462
|
}
|
|
1878
2463
|
for (const page of pages) {
|
|
1879
2464
|
const url = new URL(page.fetch.finalUrl);
|
|
1880
2465
|
const { route: selfRoute, locale: selfLocale } = splitRoute(url.pathname);
|
|
1881
|
-
record(
|
|
2466
|
+
record(rowOf2(page.fetch.finalUrl, selfRoute), selfLocale, page.fetch.finalUrl);
|
|
1882
2467
|
for (const alt of page.links.alternates) {
|
|
1883
2468
|
let altUrl;
|
|
1884
2469
|
try {
|
|
@@ -1888,7 +2473,7 @@ function buildI18nMatrix(pages, options = {}) {
|
|
|
1888
2473
|
}
|
|
1889
2474
|
const { route } = splitRoute(altUrl.pathname);
|
|
1890
2475
|
const locale = alt.isXDefault ? "x-default" : localeIdentity(alt.hreflang);
|
|
1891
|
-
record(
|
|
2476
|
+
record(rowOf2(altUrl.toString(), route || selfRoute), locale, altUrl.toString());
|
|
1892
2477
|
}
|
|
1893
2478
|
}
|
|
1894
2479
|
for (const declared of options.declaredUrls ?? []) {
|
|
@@ -1899,7 +2484,7 @@ function buildI18nMatrix(pages, options = {}) {
|
|
|
1899
2484
|
continue;
|
|
1900
2485
|
}
|
|
1901
2486
|
const { route, locale } = splitRoute(url.pathname);
|
|
1902
|
-
record(
|
|
2487
|
+
record(rowOf2(url.toString(), route), locale, url.toString());
|
|
1903
2488
|
}
|
|
1904
2489
|
const sortedLocales = sortLocales([...locales]);
|
|
1905
2490
|
const sortedRoutes = [...routes].sort((a, b) => a.localeCompare(b));
|
|
@@ -1908,11 +2493,20 @@ function buildI18nMatrix(pages, options = {}) {
|
|
|
1908
2493
|
cells[route] = {};
|
|
1909
2494
|
for (const locale of sortedLocales) {
|
|
1910
2495
|
const slot = grid.get(route)?.get(locale);
|
|
1911
|
-
cells[route][locale] = slot ? { url: slot.url, inspected: slot.inspected } : { url: null, inspected: false };
|
|
2496
|
+
cells[route][locale] = slot ? { url: slot.url, inspected: slot.inspected, source: slot.source } : { url: null, inspected: false };
|
|
1912
2497
|
}
|
|
1913
2498
|
}
|
|
1914
2499
|
return { locales: sortedLocales, routes: sortedRoutes, cells };
|
|
1915
2500
|
}
|
|
2501
|
+
function countUnverifiedAlternates(matrix) {
|
|
2502
|
+
let count2 = 0;
|
|
2503
|
+
for (const route of matrix.routes) {
|
|
2504
|
+
for (const locale of matrix.locales) {
|
|
2505
|
+
if (matrix.cells[route]?.[locale]?.source === "alternate") count2++;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
return count2;
|
|
2509
|
+
}
|
|
1916
2510
|
function reciprocityIssues(pages) {
|
|
1917
2511
|
const issues = [];
|
|
1918
2512
|
const byUrl = /* @__PURE__ */ new Map();
|
|
@@ -2013,6 +2607,9 @@ function declaredLocales(page) {
|
|
|
2013
2607
|
}
|
|
2014
2608
|
return out;
|
|
2015
2609
|
}
|
|
2610
|
+
function rowOf(site, url, pathname) {
|
|
2611
|
+
return site.clusterRouteOf?.(url) ?? splitRoute(pathname).route;
|
|
2612
|
+
}
|
|
2016
2613
|
function sitemapLocalesByRoute(site) {
|
|
2017
2614
|
const byRoute = /* @__PURE__ */ new Map();
|
|
2018
2615
|
for (const entry of site.discovery?.urls ?? []) {
|
|
@@ -2022,8 +2619,9 @@ function sitemapLocalesByRoute(site) {
|
|
|
2022
2619
|
} catch {
|
|
2023
2620
|
continue;
|
|
2024
2621
|
}
|
|
2025
|
-
const {
|
|
2622
|
+
const { locale } = splitRoute(pathname);
|
|
2026
2623
|
if (locale === "x-default") continue;
|
|
2624
|
+
const route = rowOf(site, entry.loc, pathname);
|
|
2027
2625
|
const set = byRoute.get(route) ?? /* @__PURE__ */ new Set();
|
|
2028
2626
|
set.add(locale.toLowerCase());
|
|
2029
2627
|
byRoute.set(route, set);
|
|
@@ -2087,7 +2685,7 @@ var hreflangSitemapMismatch = {
|
|
|
2087
2685
|
} catch {
|
|
2088
2686
|
continue;
|
|
2089
2687
|
}
|
|
2090
|
-
const
|
|
2688
|
+
const route = rowOf(site, page.fetch.finalUrl, pathname);
|
|
2091
2689
|
const inSitemap = bySitemap.get(route);
|
|
2092
2690
|
if (!inSitemap || inSitemap.size === 0) continue;
|
|
2093
2691
|
const onlyInSitemap = sorted(new Set([...inSitemap].filter((l) => !head.has(l))));
|
|
@@ -2167,9 +2765,46 @@ var robotsBlocksSite = {
|
|
|
2167
2765
|
});
|
|
2168
2766
|
}
|
|
2169
2767
|
};
|
|
2768
|
+
var iconsIcoMissing = {
|
|
2769
|
+
id: "icons.ico.missing",
|
|
2770
|
+
severity: "info",
|
|
2771
|
+
summary: "Serve a `/favicon.ico` at the root for the clients that ask blind",
|
|
2772
|
+
rigor: "guideline",
|
|
2773
|
+
sources: ["whatwg-html-link-types", "mdn-link-rel"],
|
|
2774
|
+
// Only judged when the probe actually ran. No probe means goflag did not
|
|
2775
|
+
// look, and a rule that reported an absence it never checked for would be
|
|
2776
|
+
// inventing a finding.
|
|
2777
|
+
appliesTo: (site) => site.favicon !== void 0,
|
|
2778
|
+
check: ({ site, issue }) => {
|
|
2779
|
+
const probe = site.favicon;
|
|
2780
|
+
if (!probe || probe.found) return [];
|
|
2781
|
+
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`;
|
|
2782
|
+
return issue({
|
|
2783
|
+
pageUrl: probe.url,
|
|
2784
|
+
message: `No \`/favicon.ico\` at the root: ${detail}. Clients that ask for it blind \u2014 feed readers, link unfurlers, older crawlers \u2014 get nothing.`,
|
|
2785
|
+
origin: { kind: "computed" },
|
|
2786
|
+
fix: {
|
|
2787
|
+
title: "Generate it from the icon you already have",
|
|
2788
|
+
snippet: [
|
|
2789
|
+
"// No framework convention emits an .ico: Next's icon.tsx goes through",
|
|
2790
|
+
"// ImageResponse, which is PNG. The container is a header plus one",
|
|
2791
|
+
"// entry per size plus the PNGs concatenated \u2014 thirty lines and no",
|
|
2792
|
+
"// dependency, over an image library you almost certainly already have.",
|
|
2793
|
+
"//",
|
|
2794
|
+
"// Fingerprint the *inputs* (the source image, the sizes), not the",
|
|
2795
|
+
"// bytes you produce: encoders are not stable across versions, so a",
|
|
2796
|
+
"// script keyed on its own output dirties the file on every commit.",
|
|
2797
|
+
"// Then a --check mode can verify it in CI without writing anything."
|
|
2798
|
+
].join("\n"),
|
|
2799
|
+
language: "ts"
|
|
2800
|
+
}
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2170
2804
|
var SITE_RULES = [
|
|
2171
2805
|
hreflangMissing,
|
|
2172
2806
|
hreflangSitemapMismatch,
|
|
2807
|
+
iconsIcoMissing,
|
|
2173
2808
|
robotsBlocksSite
|
|
2174
2809
|
];
|
|
2175
2810
|
function getSiteRule(id) {
|
|
@@ -2576,7 +3211,7 @@ function relaxTlsIfRequested2(enabled) {
|
|
|
2576
3211
|
}
|
|
2577
3212
|
|
|
2578
3213
|
// src/lib/core/clusters.ts
|
|
2579
|
-
function
|
|
3214
|
+
function normalise2(raw) {
|
|
2580
3215
|
let url;
|
|
2581
3216
|
try {
|
|
2582
3217
|
url = new URL(raw);
|
|
@@ -2588,39 +3223,140 @@ function normalise(raw) {
|
|
|
2588
3223
|
return `${url.origin}${path}${url.search}`;
|
|
2589
3224
|
}
|
|
2590
3225
|
var X_DEFAULT = "x-default";
|
|
3226
|
+
function clusterCount(rows) {
|
|
3227
|
+
return new Set(rows.values()).size;
|
|
3228
|
+
}
|
|
3229
|
+
function indexOf(rows, conflicts, refused) {
|
|
3230
|
+
return {
|
|
3231
|
+
rows,
|
|
3232
|
+
routeOf: (url) => {
|
|
3233
|
+
const key = normalise2(url);
|
|
3234
|
+
return key === null ? void 0 : rows.get(key);
|
|
3235
|
+
},
|
|
3236
|
+
size: clusterCount(rows),
|
|
3237
|
+
conflicts,
|
|
3238
|
+
refused
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
2591
3241
|
function buildClusterIndex(entries) {
|
|
2592
|
-
const
|
|
3242
|
+
const rows = /* @__PURE__ */ new Map();
|
|
2593
3243
|
const conflicts = [];
|
|
2594
|
-
|
|
3244
|
+
let refused = 0;
|
|
2595
3245
|
for (const entry of entries) {
|
|
2596
3246
|
const alternates = entry.alternates;
|
|
2597
3247
|
if (!alternates || alternates.length === 0) continue;
|
|
2598
3248
|
const xDefault = alternates.find((a) => a.hreflang.trim().toLowerCase() === X_DEFAULT);
|
|
2599
|
-
if (!xDefault)
|
|
2600
|
-
|
|
2601
|
-
|
|
3249
|
+
if (!xDefault) {
|
|
3250
|
+
refused++;
|
|
3251
|
+
continue;
|
|
3252
|
+
}
|
|
3253
|
+
const anchorUrl = normalise2(xDefault.href);
|
|
3254
|
+
if (!anchorUrl) {
|
|
3255
|
+
refused++;
|
|
3256
|
+
continue;
|
|
3257
|
+
}
|
|
2602
3258
|
const { route: anchorRoute } = splitRoute(new URL(anchorUrl).pathname);
|
|
2603
|
-
anchors.add(anchorRoute);
|
|
2604
3259
|
const members = [entry.loc, ...alternates.map((a) => a.href)];
|
|
2605
3260
|
for (const member of members) {
|
|
2606
|
-
const url =
|
|
3261
|
+
const url = normalise2(member);
|
|
2607
3262
|
if (!url) continue;
|
|
2608
|
-
const existing =
|
|
3263
|
+
const existing = rows.get(url);
|
|
2609
3264
|
if (existing === void 0) {
|
|
2610
|
-
|
|
3265
|
+
rows.set(url, anchorRoute);
|
|
2611
3266
|
continue;
|
|
2612
3267
|
}
|
|
2613
3268
|
if (existing !== anchorRoute && !conflicts.includes(url)) conflicts.push(url);
|
|
2614
3269
|
}
|
|
2615
3270
|
}
|
|
2616
|
-
return
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
3271
|
+
return indexOf(rows, conflicts, refused);
|
|
3272
|
+
}
|
|
3273
|
+
function readHeads(pages) {
|
|
3274
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3275
|
+
for (const page of pages) {
|
|
3276
|
+
const self = normalise2(page.fetch.finalUrl);
|
|
3277
|
+
if (self === null || seen.has(self)) continue;
|
|
3278
|
+
const peers = /* @__PURE__ */ new Set();
|
|
3279
|
+
const xDefaults = /* @__PURE__ */ new Set();
|
|
3280
|
+
for (const alt of page.links.alternates) {
|
|
3281
|
+
const href = normalise2(alt.href);
|
|
3282
|
+
if (href === null) continue;
|
|
3283
|
+
if (alt.isXDefault) xDefaults.add(href);
|
|
3284
|
+
else if (href !== self) peers.add(href);
|
|
3285
|
+
}
|
|
3286
|
+
seen.set(self, { peers, xDefault: xDefaults.size === 1 ? [...xDefaults][0] : null });
|
|
3287
|
+
}
|
|
3288
|
+
return seen;
|
|
3289
|
+
}
|
|
3290
|
+
function buildHeadClusterIndex(pages) {
|
|
3291
|
+
const seen = readHeads(pages);
|
|
3292
|
+
const parent = /* @__PURE__ */ new Map();
|
|
3293
|
+
for (const url of seen.keys()) parent.set(url, url);
|
|
3294
|
+
const find = (start) => {
|
|
3295
|
+
let root = start;
|
|
3296
|
+
while (parent.get(root) !== root) root = parent.get(root);
|
|
3297
|
+
let cursor = start;
|
|
3298
|
+
while (parent.get(cursor) !== root) {
|
|
3299
|
+
const next = parent.get(cursor);
|
|
3300
|
+
parent.set(cursor, root);
|
|
3301
|
+
cursor = next;
|
|
3302
|
+
}
|
|
3303
|
+
return root;
|
|
3304
|
+
};
|
|
3305
|
+
for (const [url, decl] of seen) {
|
|
3306
|
+
for (const peer of decl.peers) {
|
|
3307
|
+
if (!seen.get(peer)?.peers.has(url)) continue;
|
|
3308
|
+
const [a, b] = [find(url), find(peer)];
|
|
3309
|
+
if (a !== b) parent.set(a, b);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
const components = /* @__PURE__ */ new Map();
|
|
3313
|
+
for (const url of seen.keys()) {
|
|
3314
|
+
const root = find(url);
|
|
3315
|
+
const members = components.get(root);
|
|
3316
|
+
if (members) members.push(url);
|
|
3317
|
+
else components.set(root, [url]);
|
|
3318
|
+
}
|
|
3319
|
+
const rows = /* @__PURE__ */ new Map();
|
|
3320
|
+
let refused = 0;
|
|
3321
|
+
for (const members of components.values()) {
|
|
3322
|
+
if (members.length < 2) continue;
|
|
3323
|
+
const declared = new Set(
|
|
3324
|
+
members.map((m) => seen.get(m)?.xDefault).filter((x) => Boolean(x))
|
|
3325
|
+
);
|
|
3326
|
+
if (declared.size !== 1) {
|
|
3327
|
+
refused++;
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
const anchor = [...declared][0];
|
|
3331
|
+
if (!members.includes(anchor)) {
|
|
3332
|
+
refused++;
|
|
3333
|
+
continue;
|
|
3334
|
+
}
|
|
3335
|
+
const { route } = splitRoute(new URL(anchor).pathname);
|
|
3336
|
+
for (const member of members) rows.set(member, route);
|
|
3337
|
+
}
|
|
3338
|
+
return indexOf(rows, [], refused);
|
|
3339
|
+
}
|
|
3340
|
+
function combineClusterIndexes(primary, secondary) {
|
|
3341
|
+
const rows = new Map(secondary.rows);
|
|
3342
|
+
const conflicts = [...primary.conflicts];
|
|
3343
|
+
const add = (url) => {
|
|
3344
|
+
if (!conflicts.includes(url)) conflicts.push(url);
|
|
2623
3345
|
};
|
|
3346
|
+
for (const url of secondary.conflicts) add(url);
|
|
3347
|
+
for (const [url, route] of primary.rows) {
|
|
3348
|
+
const claimed = rows.get(url);
|
|
3349
|
+
if (claimed !== void 0 && claimed !== route) add(url);
|
|
3350
|
+
rows.set(url, route);
|
|
3351
|
+
}
|
|
3352
|
+
return indexOf(rows, conflicts, primary.refused + secondary.refused);
|
|
3353
|
+
}
|
|
3354
|
+
function clustersOnlyFrom(primary, secondary) {
|
|
3355
|
+
const routes = /* @__PURE__ */ new Set();
|
|
3356
|
+
for (const [url, route] of secondary.rows) {
|
|
3357
|
+
if (!primary.rows.has(url)) routes.add(route);
|
|
3358
|
+
}
|
|
3359
|
+
return routes.size;
|
|
2624
3360
|
}
|
|
2625
3361
|
|
|
2626
3362
|
// src/lib/core/coverage.ts
|
|
@@ -2707,6 +3443,98 @@ function selectByStructure(urls, options = {}) {
|
|
|
2707
3443
|
};
|
|
2708
3444
|
}
|
|
2709
3445
|
|
|
3446
|
+
// src/lib/core/probes/assets.ts
|
|
3447
|
+
var HEAD_BYTES = 8 * 1024;
|
|
3448
|
+
function isImageContentType(contentType) {
|
|
3449
|
+
if (!contentType) return false;
|
|
3450
|
+
return contentType.startsWith("image/") || contentType === "application/ico" || contentType === "application/x-ico";
|
|
3451
|
+
}
|
|
3452
|
+
async function probeAsset(url, options = {}) {
|
|
3453
|
+
const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 8e3);
|
|
3454
|
+
try {
|
|
3455
|
+
const res = await fetch(url, {
|
|
3456
|
+
signal,
|
|
3457
|
+
redirect: "follow",
|
|
3458
|
+
headers: { range: `bytes=0-${HEAD_BYTES - 1}`, accept: "image/*,*/*;q=0.8" }
|
|
3459
|
+
});
|
|
3460
|
+
const contentType = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
|
|
3461
|
+
const served = res.status === 200 || res.status === 206;
|
|
3462
|
+
const ok = served && isImageContentType(contentType);
|
|
3463
|
+
const head = served ? await readHead(res) : void 0;
|
|
3464
|
+
const sizes = head ? decodeSizes(head) : void 0;
|
|
3465
|
+
return { url, status: res.status, ok, contentType, ...sizes ? { sizes } : {} };
|
|
3466
|
+
} catch {
|
|
3467
|
+
return { url, status: 0, ok: false };
|
|
3468
|
+
} finally {
|
|
3469
|
+
cleanup();
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
async function readHead(res) {
|
|
3473
|
+
if (!res.body) return void 0;
|
|
3474
|
+
const reader = res.body.getReader();
|
|
3475
|
+
const chunks = [];
|
|
3476
|
+
let total = 0;
|
|
3477
|
+
try {
|
|
3478
|
+
while (total < HEAD_BYTES) {
|
|
3479
|
+
const { done, value } = await reader.read();
|
|
3480
|
+
if (done) break;
|
|
3481
|
+
if (value) {
|
|
3482
|
+
chunks.push(value);
|
|
3483
|
+
total += value.byteLength;
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
} catch {
|
|
3487
|
+
return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
|
|
3488
|
+
} finally {
|
|
3489
|
+
await reader.cancel().catch(() => void 0);
|
|
3490
|
+
}
|
|
3491
|
+
return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
|
|
3492
|
+
}
|
|
3493
|
+
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
3494
|
+
function decodeSizes(head) {
|
|
3495
|
+
if (head.length >= 24 && head.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
3496
|
+
return [{ width: head.readUInt32BE(16), height: head.readUInt32BE(20) }];
|
|
3497
|
+
}
|
|
3498
|
+
if (head.length >= 6 && head.readUInt16LE(0) === 0 && head.readUInt16LE(2) === 1) {
|
|
3499
|
+
const count2 = head.readUInt16LE(4);
|
|
3500
|
+
if (count2 === 0 || head.length < 6 + 16 * count2) return void 0;
|
|
3501
|
+
const sizes = [];
|
|
3502
|
+
for (let index = 0; index < count2; index++) {
|
|
3503
|
+
const at = 6 + 16 * index;
|
|
3504
|
+
sizes.push({ width: head[at] || 256, height: head[at + 1] || 256 });
|
|
3505
|
+
}
|
|
3506
|
+
return sizes;
|
|
3507
|
+
}
|
|
3508
|
+
return void 0;
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
// src/lib/core/probes/favicon.ts
|
|
3512
|
+
async function probeFavicon(origin, options = {}) {
|
|
3513
|
+
const url = new URL("/favicon.ico", origin).toString();
|
|
3514
|
+
const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 5e3);
|
|
3515
|
+
try {
|
|
3516
|
+
let res = await fetch(url, { method: "HEAD", signal, redirect: "follow" });
|
|
3517
|
+
if (res.status === 405 || res.status === 501) {
|
|
3518
|
+
res = await fetch(url, { signal, redirect: "follow" });
|
|
3519
|
+
}
|
|
3520
|
+
const contentType = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
|
|
3521
|
+
return {
|
|
3522
|
+
url,
|
|
3523
|
+
status: res.status,
|
|
3524
|
+
found: res.ok && isIconType(contentType),
|
|
3525
|
+
contentType
|
|
3526
|
+
};
|
|
3527
|
+
} catch {
|
|
3528
|
+
return { url, status: 0, found: false };
|
|
3529
|
+
} finally {
|
|
3530
|
+
cleanup();
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
function isIconType(contentType) {
|
|
3534
|
+
if (!contentType) return false;
|
|
3535
|
+
return contentType.startsWith("image/") || contentType === "application/ico" || contentType === "application/x-ico";
|
|
3536
|
+
}
|
|
3537
|
+
|
|
2710
3538
|
// src/lib/rules/advisory.ts
|
|
2711
3539
|
function resolveEvidencePath(extraction, path) {
|
|
2712
3540
|
let current = extraction;
|
|
@@ -3374,6 +4202,18 @@ async function runLinkAudit(discovery, options = {}) {
|
|
|
3374
4202
|
{
|
|
3375
4203
|
concurrency: options.checkConcurrency ?? DEFAULTS3.checkConcurrency,
|
|
3376
4204
|
perHost: options.maxPerHost ?? DEFAULTS3.maxPerHost,
|
|
4205
|
+
// The origin is exempt, and the scan phase above is the precedent: it
|
|
4206
|
+
// fetches these same pages at `scanConcurrency` with `perHost: Infinity`.
|
|
4207
|
+
// Capping the check phase at 3 against that host made the two phases
|
|
4208
|
+
// disagree about the same server, and on a deployed site it is where the
|
|
4209
|
+
// audit spends itself: measured on openfinanceguide, 3821 same-origin
|
|
4210
|
+
// links at ~700 ms a round trip, three at a time — 14.8 minutes of an
|
|
4211
|
+
// 18.5-minute job. Against a local `--start` run the cap never binds,
|
|
4212
|
+
// which is why the plan's own 7-minute figure held and CI's did not.
|
|
4213
|
+
//
|
|
4214
|
+
// `checkConcurrency` still bounds it: at most 8 in flight, where the scan
|
|
4215
|
+
// phase already runs 4.
|
|
4216
|
+
unlimitedHost: hostOf(discovery.origin),
|
|
3377
4217
|
signal: options.signal
|
|
3378
4218
|
},
|
|
3379
4219
|
async (url) => {
|
|
@@ -3457,7 +4297,8 @@ function mapWithHostCaps(items, opts, worker, hostOf2) {
|
|
|
3457
4297
|
let pickIndex = -1;
|
|
3458
4298
|
for (let k = 0; k < pending.length; k++) {
|
|
3459
4299
|
const entry = pending[k];
|
|
3460
|
-
|
|
4300
|
+
const cap = entry.host === opts.unlimitedHost ? Infinity : perHost;
|
|
4301
|
+
if ((hostActive.get(entry.host) ?? 0) < cap) {
|
|
3461
4302
|
pickIndex = k;
|
|
3462
4303
|
break;
|
|
3463
4304
|
}
|
|
@@ -3590,6 +4431,71 @@ function exitCode(report, failOn = "warning") {
|
|
|
3590
4431
|
if (failOn === "error") return report.summary.verdict === "red" ? 1 : 0;
|
|
3591
4432
|
return 1;
|
|
3592
4433
|
}
|
|
4434
|
+
async function attachManifests(pages, options) {
|
|
4435
|
+
const wanted = new Set(
|
|
4436
|
+
pages.map((page) => page.links.manifest?.href).filter((href) => Boolean(href))
|
|
4437
|
+
);
|
|
4438
|
+
if (wanted.size === 0) return;
|
|
4439
|
+
const probed = /* @__PURE__ */ new Map();
|
|
4440
|
+
await Promise.all(
|
|
4441
|
+
[...wanted].map(async (href) => {
|
|
4442
|
+
const probe = await probeManifest(href, options).catch(() => void 0);
|
|
4443
|
+
if (probe) probed.set(href, probe);
|
|
4444
|
+
})
|
|
4445
|
+
);
|
|
4446
|
+
for (const page of pages) {
|
|
4447
|
+
const href = page.links.manifest?.href;
|
|
4448
|
+
const probe = href ? probed.get(href) : void 0;
|
|
4449
|
+
if (probe) page.probes = { ...page.probes, manifest: probe };
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
async function probeAssets(pages, options) {
|
|
4453
|
+
const sameOrigin = (url) => {
|
|
4454
|
+
try {
|
|
4455
|
+
return new URL(url).origin === new URL(options.origin).origin;
|
|
4456
|
+
} catch {
|
|
4457
|
+
return false;
|
|
4458
|
+
}
|
|
4459
|
+
};
|
|
4460
|
+
const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal || sameOrigin(url));
|
|
4461
|
+
const wanted = /* @__PURE__ */ new Set();
|
|
4462
|
+
for (const page of pages) {
|
|
4463
|
+
for (const image of page.openGraph.images) {
|
|
4464
|
+
const url = image.url.value.trim();
|
|
4465
|
+
if (wantedUrl(url)) wanted.add(url);
|
|
4466
|
+
}
|
|
4467
|
+
for (const icon of page.links.icons) {
|
|
4468
|
+
if (wantedUrl(icon.href)) wanted.add(icon.href);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
if (wanted.size === 0) return;
|
|
4472
|
+
const queue = [...wanted];
|
|
4473
|
+
const probed = /* @__PURE__ */ new Map();
|
|
4474
|
+
const workers = Math.min(options.concurrency ?? 6, queue.length);
|
|
4475
|
+
await Promise.all(
|
|
4476
|
+
Array.from({ length: workers }, async () => {
|
|
4477
|
+
for (let url = queue.pop(); url !== void 0; url = queue.pop()) {
|
|
4478
|
+
const probe = await probeAsset(url, {
|
|
4479
|
+
signal: options.signal,
|
|
4480
|
+
timeoutMs: options.timeoutMs
|
|
4481
|
+
}).catch(() => void 0);
|
|
4482
|
+
if (probe) probed.set(url, probe);
|
|
4483
|
+
}
|
|
4484
|
+
})
|
|
4485
|
+
);
|
|
4486
|
+
for (const page of pages) {
|
|
4487
|
+
const mine = {};
|
|
4488
|
+
for (const image of page.openGraph.images) {
|
|
4489
|
+
const probe = probed.get(image.url.value.trim());
|
|
4490
|
+
if (probe) mine[probe.url] = probe;
|
|
4491
|
+
}
|
|
4492
|
+
for (const icon of page.links.icons) {
|
|
4493
|
+
const probe = probed.get(icon.href);
|
|
4494
|
+
if (probe) mine[probe.url] = probe;
|
|
4495
|
+
}
|
|
4496
|
+
page.assets = mine;
|
|
4497
|
+
}
|
|
4498
|
+
}
|
|
3593
4499
|
function dropCanonicalDuplicates(pages) {
|
|
3594
4500
|
const crawled = new Set(pages.map((p) => routeKey(p.fetch.finalUrl)));
|
|
3595
4501
|
const kept = pages.filter((page) => {
|
|
@@ -3626,7 +4532,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3626
4532
|
crawlFallback: false
|
|
3627
4533
|
}).catch(() => void 0);
|
|
3628
4534
|
const allSitemapUrls = (discovery?.urls ?? []).map((u) => u.loc);
|
|
3629
|
-
const
|
|
4535
|
+
const sitemapClusters = buildClusterIndex(discovery?.urls ?? []);
|
|
3630
4536
|
const coverageMode = options.coverage ?? (allSitemapUrls.length > 0 ? "structural" : "all");
|
|
3631
4537
|
const selection = coverageMode === "structural" && allSitemapUrls.length > 0 ? selectByStructure(allSitemapUrls, { locales: options.locales }) : void 0;
|
|
3632
4538
|
const sitemapUrls = selection?.urls ?? allSitemapUrls;
|
|
@@ -3635,6 +4541,10 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3635
4541
|
signal: options.signal,
|
|
3636
4542
|
timeoutMs: options.timeoutMs
|
|
3637
4543
|
}).catch(() => void 0);
|
|
4544
|
+
const favicon = await probeFavicon(origin, {
|
|
4545
|
+
signal: options.signal,
|
|
4546
|
+
timeoutMs: options.timeoutMs
|
|
4547
|
+
}).catch(() => void 0);
|
|
3638
4548
|
const crawlResult = await crawl({
|
|
3639
4549
|
entryUrl: entry,
|
|
3640
4550
|
seedUrls: sitemapUrls,
|
|
@@ -3681,6 +4591,13 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3681
4591
|
status: 0
|
|
3682
4592
|
}))
|
|
3683
4593
|
];
|
|
4594
|
+
await attachManifests(htmlPages, { signal: options.signal, timeoutMs: options.timeoutMs });
|
|
4595
|
+
await probeAssets(htmlPages, {
|
|
4596
|
+
origin,
|
|
4597
|
+
checkExternal: options.checkExternal ?? true,
|
|
4598
|
+
signal: options.signal,
|
|
4599
|
+
timeoutMs: options.timeoutMs
|
|
4600
|
+
});
|
|
3684
4601
|
const seoIssues = [];
|
|
3685
4602
|
const conformanceRows = [];
|
|
3686
4603
|
const advisories = [];
|
|
@@ -3729,6 +4646,9 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3729
4646
|
);
|
|
3730
4647
|
}
|
|
3731
4648
|
if (!ICU_KNOWS_LANGUAGES) warnings.push(ICU_UNAVAILABLE_WARNING);
|
|
4649
|
+
const headClusters = buildHeadClusterIndex(htmlPages);
|
|
4650
|
+
const clusters = combineClusterIndexes(sitemapClusters, headClusters);
|
|
4651
|
+
const clustersFromHead = clustersOnlyFrom(sitemapClusters, headClusters);
|
|
3732
4652
|
const matrix = buildI18nMatrix(htmlPages, {
|
|
3733
4653
|
declaredUrls: sitemapUrls,
|
|
3734
4654
|
locales: localeAxis.locales,
|
|
@@ -3736,7 +4656,13 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3736
4656
|
});
|
|
3737
4657
|
if (clusters.conflicts.length > 0) {
|
|
3738
4658
|
warnings.push(
|
|
3739
|
-
`${clusters.conflicts.length} URL(s) are declared in two different hreflang clusters by the sitemap
|
|
4659
|
+
`${clusters.conflicts.length} URL(s) are declared in two different hreflang clusters by the sitemap and/or the pages' \`<head>\`; the sitemap's declaration was kept: ${clusters.conflicts.slice(0, 3).join(", ")}${clusters.conflicts.length > 3 ? `, +${clusters.conflicts.length - 3} more` : ""}.`
|
|
4660
|
+
);
|
|
4661
|
+
}
|
|
4662
|
+
const unverifiedAlternates = countUnverifiedAlternates(matrix);
|
|
4663
|
+
if (unverifiedAlternates > 0) {
|
|
4664
|
+
warnings.push(
|
|
4665
|
+
`${unverifiedAlternates} translation${unverifiedAlternates === 1 ? "" : "s"} in the matrix ${unverifiedAlternates === 1 ? "is" : "are"} vouched for only by an \`hreflang\` on another page \u2014 not crawled, and not in the sitemap. They are counted as present, so a page advertised but not served reads as translated rather than missing.`
|
|
3740
4666
|
);
|
|
3741
4667
|
}
|
|
3742
4668
|
const holes = localeAxis.multilingual ? deriveTranslationHoles(matrix, options.ignoreHoles) : [];
|
|
@@ -3757,7 +4683,12 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3757
4683
|
matrix,
|
|
3758
4684
|
localeAxis,
|
|
3759
4685
|
discovery,
|
|
3760
|
-
robots
|
|
4686
|
+
robots,
|
|
4687
|
+
favicon,
|
|
4688
|
+
// Same index the matrix was built from, so a rule that groups by route
|
|
4689
|
+
// and the grid that reports holes cannot disagree about which URLs are
|
|
4690
|
+
// one page.
|
|
4691
|
+
clusterRouteOf: clusters.routeOf
|
|
3761
4692
|
};
|
|
3762
4693
|
const siteOccurrence = /* @__PURE__ */ new Map();
|
|
3763
4694
|
const siteIssues = lintSite(siteContext).map((draft) => {
|
|
@@ -3883,13 +4814,22 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
3883
4814
|
},
|
|
3884
4815
|
...ignoredHoles > 0 ? { ignoredHoles } : {},
|
|
3885
4816
|
...duplicatePages > 0 ? { duplicatePages } : {},
|
|
4817
|
+
...unverifiedAlternates > 0 ? { unverifiedAlternates } : {},
|
|
3886
4818
|
// Only when the site declared clusters. A merge that changes which rows
|
|
3887
4819
|
// exist must be visible: the alternative is a hole count that moved for
|
|
3888
4820
|
// a reason nothing in the report explains.
|
|
3889
|
-
...clusters.size > 0 ? {
|
|
4821
|
+
...clusters.size > 0 || clusters.refused > 0 ? {
|
|
3890
4822
|
declaredClusters: {
|
|
3891
4823
|
count: clusters.size,
|
|
3892
|
-
...clusters.conflicts.length > 0 ? { conflicts: clusters.conflicts.length } : {}
|
|
4824
|
+
...clusters.conflicts.length > 0 ? { conflicts: clusters.conflicts.length } : {},
|
|
4825
|
+
// Which source did the work. On a site that declares both ways
|
|
4826
|
+
// the two indexes describe the same clusters, so this counts the
|
|
4827
|
+
// rows only the `<head>` supplied rather than a difference of
|
|
4828
|
+
// totals that would claim a gain that is not there.
|
|
4829
|
+
...clustersFromHead > 0 ? { fromHead: clustersFromHead } : {},
|
|
4830
|
+
// Declared and not used, for want of an anchor to name the row
|
|
4831
|
+
// after. A declaration we saw and did not act on has to be said.
|
|
4832
|
+
...clusters.refused > 0 ? { refused: clusters.refused } : {}
|
|
3893
4833
|
}
|
|
3894
4834
|
} : {},
|
|
3895
4835
|
...discovery ? {
|
|
@@ -4566,4 +5506,4 @@ export {
|
|
|
4566
5506
|
summarize,
|
|
4567
5507
|
Logger
|
|
4568
5508
|
};
|
|
4569
|
-
//# sourceMappingURL=chunk-
|
|
5509
|
+
//# sourceMappingURL=chunk-TAZEAIFI.js.map
|