@goflag/cli 0.2.8 → 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.
@@ -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
- const hasOtherOg = Boolean(
1573
- ex.openGraph.title?.value || ex.openGraph.images.length > 0 || ex.openGraph.url?.value
1574
- );
1575
- if (!hasOtherOg) return { status: "na", observed: null };
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.links.manifest ? { href: page.links.manifest.href } : void 0,
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,
@@ -2185,9 +2765,46 @@ var robotsBlocksSite = {
2185
2765
  });
2186
2766
  }
2187
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
+ };
2188
2804
  var SITE_RULES = [
2189
2805
  hreflangMissing,
2190
2806
  hreflangSitemapMismatch,
2807
+ iconsIcoMissing,
2191
2808
  robotsBlocksSite
2192
2809
  ];
2193
2810
  function getSiteRule(id) {
@@ -2594,7 +3211,7 @@ function relaxTlsIfRequested2(enabled) {
2594
3211
  }
2595
3212
 
2596
3213
  // src/lib/core/clusters.ts
2597
- function normalise(raw) {
3214
+ function normalise2(raw) {
2598
3215
  let url;
2599
3216
  try {
2600
3217
  url = new URL(raw);
@@ -2613,7 +3230,7 @@ function indexOf(rows, conflicts, refused) {
2613
3230
  return {
2614
3231
  rows,
2615
3232
  routeOf: (url) => {
2616
- const key = normalise(url);
3233
+ const key = normalise2(url);
2617
3234
  return key === null ? void 0 : rows.get(key);
2618
3235
  },
2619
3236
  size: clusterCount(rows),
@@ -2633,7 +3250,7 @@ function buildClusterIndex(entries) {
2633
3250
  refused++;
2634
3251
  continue;
2635
3252
  }
2636
- const anchorUrl = normalise(xDefault.href);
3253
+ const anchorUrl = normalise2(xDefault.href);
2637
3254
  if (!anchorUrl) {
2638
3255
  refused++;
2639
3256
  continue;
@@ -2641,7 +3258,7 @@ function buildClusterIndex(entries) {
2641
3258
  const { route: anchorRoute } = splitRoute(new URL(anchorUrl).pathname);
2642
3259
  const members = [entry.loc, ...alternates.map((a) => a.href)];
2643
3260
  for (const member of members) {
2644
- const url = normalise(member);
3261
+ const url = normalise2(member);
2645
3262
  if (!url) continue;
2646
3263
  const existing = rows.get(url);
2647
3264
  if (existing === void 0) {
@@ -2656,12 +3273,12 @@ function buildClusterIndex(entries) {
2656
3273
  function readHeads(pages) {
2657
3274
  const seen = /* @__PURE__ */ new Map();
2658
3275
  for (const page of pages) {
2659
- const self = normalise(page.fetch.finalUrl);
3276
+ const self = normalise2(page.fetch.finalUrl);
2660
3277
  if (self === null || seen.has(self)) continue;
2661
3278
  const peers = /* @__PURE__ */ new Set();
2662
3279
  const xDefaults = /* @__PURE__ */ new Set();
2663
3280
  for (const alt of page.links.alternates) {
2664
- const href = normalise(alt.href);
3281
+ const href = normalise2(alt.href);
2665
3282
  if (href === null) continue;
2666
3283
  if (alt.isXDefault) xDefaults.add(href);
2667
3284
  else if (href !== self) peers.add(href);
@@ -2826,6 +3443,98 @@ function selectByStructure(urls, options = {}) {
2826
3443
  };
2827
3444
  }
2828
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
+
2829
3538
  // src/lib/rules/advisory.ts
2830
3539
  function resolveEvidencePath(extraction, path) {
2831
3540
  let current = extraction;
@@ -3493,6 +4202,18 @@ async function runLinkAudit(discovery, options = {}) {
3493
4202
  {
3494
4203
  concurrency: options.checkConcurrency ?? DEFAULTS3.checkConcurrency,
3495
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),
3496
4217
  signal: options.signal
3497
4218
  },
3498
4219
  async (url) => {
@@ -3576,7 +4297,8 @@ function mapWithHostCaps(items, opts, worker, hostOf2) {
3576
4297
  let pickIndex = -1;
3577
4298
  for (let k = 0; k < pending.length; k++) {
3578
4299
  const entry = pending[k];
3579
- if ((hostActive.get(entry.host) ?? 0) < perHost) {
4300
+ const cap = entry.host === opts.unlimitedHost ? Infinity : perHost;
4301
+ if ((hostActive.get(entry.host) ?? 0) < cap) {
3580
4302
  pickIndex = k;
3581
4303
  break;
3582
4304
  }
@@ -3709,6 +4431,71 @@ function exitCode(report, failOn = "warning") {
3709
4431
  if (failOn === "error") return report.summary.verdict === "red" ? 1 : 0;
3710
4432
  return 1;
3711
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
+ }
3712
4499
  function dropCanonicalDuplicates(pages) {
3713
4500
  const crawled = new Set(pages.map((p) => routeKey(p.fetch.finalUrl)));
3714
4501
  const kept = pages.filter((page) => {
@@ -3754,6 +4541,10 @@ async function runAudit(inputUrl, options = {}) {
3754
4541
  signal: options.signal,
3755
4542
  timeoutMs: options.timeoutMs
3756
4543
  }).catch(() => void 0);
4544
+ const favicon = await probeFavicon(origin, {
4545
+ signal: options.signal,
4546
+ timeoutMs: options.timeoutMs
4547
+ }).catch(() => void 0);
3757
4548
  const crawlResult = await crawl({
3758
4549
  entryUrl: entry,
3759
4550
  seedUrls: sitemapUrls,
@@ -3800,6 +4591,13 @@ async function runAudit(inputUrl, options = {}) {
3800
4591
  status: 0
3801
4592
  }))
3802
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
+ });
3803
4601
  const seoIssues = [];
3804
4602
  const conformanceRows = [];
3805
4603
  const advisories = [];
@@ -3886,6 +4684,7 @@ async function runAudit(inputUrl, options = {}) {
3886
4684
  localeAxis,
3887
4685
  discovery,
3888
4686
  robots,
4687
+ favicon,
3889
4688
  // Same index the matrix was built from, so a rule that groups by route
3890
4689
  // and the grid that reports holes cannot disagree about which URLs are
3891
4690
  // one page.
@@ -4707,4 +5506,4 @@ export {
4707
5506
  summarize,
4708
5507
  Logger
4709
5508
  };
4710
- //# sourceMappingURL=chunk-SKX7UCGC.js.map
5509
+ //# sourceMappingURL=chunk-TAZEAIFI.js.map