@goflag/cli 0.2.10 → 0.2.12
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 +123 -43
- package/dist/{chunk-DKGBLJ4H.js → chunk-QKXUKHXB.js} +1087 -82
- package/dist/chunk-QKXUKHXB.js.map +1 -0
- package/dist/cli.js +118 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +132 -2
- package/dist/index.js +3 -1
- package/flags.json +1 -1
- package/package.json +2 -2
- package/rules.json +105 -10
- package/dist/chunk-DKGBLJ4H.js.map +0 -1
|
@@ -67,15 +67,17 @@ function parseJsonLdScripts(scripts) {
|
|
|
67
67
|
}
|
|
68
68
|
return blocks;
|
|
69
69
|
}
|
|
70
|
+
var MAX_DEPTH = 100;
|
|
70
71
|
function extractTypes(node) {
|
|
71
72
|
const out = /* @__PURE__ */ new Set();
|
|
72
|
-
visit(node, out);
|
|
73
|
+
visit(node, out, 0);
|
|
73
74
|
return [...out];
|
|
74
75
|
}
|
|
75
|
-
function visit(node, out) {
|
|
76
|
+
function visit(node, out, depth) {
|
|
76
77
|
if (node === null || node === void 0) return;
|
|
78
|
+
if (depth > MAX_DEPTH) return;
|
|
77
79
|
if (Array.isArray(node)) {
|
|
78
|
-
for (const item of node) visit(item, out);
|
|
80
|
+
for (const item of node) visit(item, out, depth + 1);
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
81
83
|
if (typeof node !== "object") return;
|
|
@@ -90,11 +92,11 @@ function visit(node, out) {
|
|
|
90
92
|
}
|
|
91
93
|
const graph = obj["@graph"];
|
|
92
94
|
if (Array.isArray(graph)) {
|
|
93
|
-
for (const item of graph) visit(item, out);
|
|
95
|
+
for (const item of graph) visit(item, out, depth + 1);
|
|
94
96
|
}
|
|
95
97
|
for (const [k, v] of Object.entries(obj)) {
|
|
96
98
|
if (k === "@type" || k === "@graph") continue;
|
|
97
|
-
if (v && typeof v === "object") visit(v, out);
|
|
99
|
+
if (v && typeof v === "object") visit(v, out, depth + 1);
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
function decodeHtmlEntities(s) {
|
|
@@ -1433,14 +1435,14 @@ function normalise(value) {
|
|
|
1433
1435
|
return value.trim().toLowerCase().split(/\s+/).sort().join(" ");
|
|
1434
1436
|
}
|
|
1435
1437
|
function sameAsset(href, src) {
|
|
1436
|
-
const
|
|
1438
|
+
const pathOf2 = (value) => {
|
|
1437
1439
|
try {
|
|
1438
1440
|
return new URL(value, "https://goflag.invalid").pathname;
|
|
1439
1441
|
} catch {
|
|
1440
1442
|
return value.split("?")[0] ?? value;
|
|
1441
1443
|
}
|
|
1442
1444
|
};
|
|
1443
|
-
return
|
|
1445
|
+
return pathOf2(href) === pathOf2(src);
|
|
1444
1446
|
}
|
|
1445
1447
|
function hasOpenGraph(ex) {
|
|
1446
1448
|
return Boolean(
|
|
@@ -1834,6 +1836,46 @@ var ogImageAlt = {
|
|
|
1834
1836
|
};
|
|
1835
1837
|
}
|
|
1836
1838
|
};
|
|
1839
|
+
var ogImageAltCaption = {
|
|
1840
|
+
id: "og.image.alt.caption",
|
|
1841
|
+
kind: "boolean",
|
|
1842
|
+
category: "opengraph",
|
|
1843
|
+
severity: "info",
|
|
1844
|
+
title: "`og:image:alt` describes the image, it does not repeat the title",
|
|
1845
|
+
why: "The protocol asks for a description of what is in the image, and says in the same breath that it is not a caption. A copy of the title is the one value that is always available and never describes anything: a reader who cannot see the card is handed the same words twice and learns nothing about the picture. It also satisfies `og.image.alt` forever, which is why it needs an id of its own.",
|
|
1846
|
+
rigor: "guideline",
|
|
1847
|
+
sources: ["ogp"],
|
|
1848
|
+
reads: ["openGraph.images", "openGraph.title", "document.title"],
|
|
1849
|
+
expected: "an `og:image:alt` that says something the title does not",
|
|
1850
|
+
relates: ["og.image.alt", "og.image.representative"],
|
|
1851
|
+
fix: {
|
|
1852
|
+
title: "Say what the card shows, not what the page is called",
|
|
1853
|
+
snippet: [
|
|
1854
|
+
"// The card is a title on a background. The alt says so \u2014 once, from a",
|
|
1855
|
+
"// template, so every page gets one and none of them is the title alone.",
|
|
1856
|
+
"const ogAlt = (title: string) => `The title \u201C${title}\u201D on a dark preview card.`;"
|
|
1857
|
+
].join("\n"),
|
|
1858
|
+
language: "ts"
|
|
1859
|
+
},
|
|
1860
|
+
evaluate: (ex) => {
|
|
1861
|
+
const described = ex.openGraph.images.filter((image) => image.alt?.value.trim());
|
|
1862
|
+
if (described.length === 0) return { status: "na", observed: null };
|
|
1863
|
+
const title = (ex.openGraph.title?.value ?? ex.document.title?.value ?? "").trim();
|
|
1864
|
+
if (title === "") return { status: "na", observed: null };
|
|
1865
|
+
const observed = described.map((image) => ({
|
|
1866
|
+
url: image.url.value,
|
|
1867
|
+
alt: image.alt?.value ?? ""
|
|
1868
|
+
}));
|
|
1869
|
+
const captions = observed.filter((image) => image.alt.trim() === title);
|
|
1870
|
+
if (captions.length === 0) return { status: "pass", observed };
|
|
1871
|
+
return {
|
|
1872
|
+
status: "fail",
|
|
1873
|
+
observed,
|
|
1874
|
+
message: captions.length === observed.length ? "`og:image:alt` repeats the title instead of describing the image." : `${captions.length} of ${observed.length} \`og:image:alt\` values repeat the title.`,
|
|
1875
|
+
origin: { kind: "meta", property: "og:image:alt" }
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1837
1879
|
var ogImageDimensions = {
|
|
1838
1880
|
id: "og.image.dimensions",
|
|
1839
1881
|
kind: "boolean",
|
|
@@ -2134,6 +2176,57 @@ var ogImageReachable = {
|
|
|
2134
2176
|
};
|
|
2135
2177
|
}
|
|
2136
2178
|
};
|
|
2179
|
+
var ogImageSizesMismatch = {
|
|
2180
|
+
id: "og.image.sizes-mismatch",
|
|
2181
|
+
kind: "boolean",
|
|
2182
|
+
category: "opengraph",
|
|
2183
|
+
severity: "warning",
|
|
2184
|
+
title: "`og:image:width` and `og:image:height` must be the image's real size",
|
|
2185
|
+
why: "The declared size is what a crawler lays the card out with before it has the file, which is the whole reason `og.image.dimensions` asks for it. A wrong one is therefore worse than none: the crawler reserves a shape the image does not have, and the card is letterboxed or cropped on the first share \u2014 the one that matters most.\n\nIt is also the declaration that blinds every other check. `og.image.ratio` reads these two numbers and refuses to fetch, so an invented 1200\xD7630 scores 1.9 and passes over an image that is square. Found on a site whose cover art is 1024\xD71024 and one of whose covers is a 337-byte 1\xD71 placeholder, all three declared 1200\xD7630 by a library that had never looked at them.",
|
|
2186
|
+
rigor: "guideline",
|
|
2187
|
+
sources: ["ogp", "meta-og-sharing"],
|
|
2188
|
+
reads: ["openGraph.images", "assets"],
|
|
2189
|
+
expected: "declared dimensions equal to the file's own",
|
|
2190
|
+
relates: ["og.image.dimensions", "og.image.ratio", "og.image.reachable"],
|
|
2191
|
+
fix: {
|
|
2192
|
+
title: "Measure the image, or let the file convention do it",
|
|
2193
|
+
snippet: [
|
|
2194
|
+
"// app/\u2026/opengraph-image.tsx \u2014 `size` is the truth and the declaration at once",
|
|
2195
|
+
"export const size = { width: 1200, height: 630 };",
|
|
2196
|
+
"",
|
|
2197
|
+
"// Naming a file instead? Then the numbers are that file's, not a default.",
|
|
2198
|
+
"openGraph: { images: [{ url: cover.url, width: cover.width, height: cover.height }] }"
|
|
2199
|
+
].join("\n"),
|
|
2200
|
+
language: "tsx"
|
|
2201
|
+
},
|
|
2202
|
+
evaluate: (ex) => {
|
|
2203
|
+
if (!ex.assets) return { status: "na", observed: null };
|
|
2204
|
+
const comparable = ex.openGraph.images.map((image) => ({ image, probe: ex.assets?.[image.url.value.trim()] })).filter(
|
|
2205
|
+
(pair) => (
|
|
2206
|
+
// Both sides have to have said something. A format goflag does not
|
|
2207
|
+
// decode leaves `sizes` absent, and absent means unknown — which is
|
|
2208
|
+
// not a mismatch.
|
|
2209
|
+
Boolean(pair.probe?.ok && pair.probe.sizes?.length) && pair.image.width?.value !== void 0 && pair.image.height?.value !== void 0
|
|
2210
|
+
)
|
|
2211
|
+
);
|
|
2212
|
+
if (comparable.length === 0) return { status: "na", observed: null };
|
|
2213
|
+
const observed = comparable.map(({ image, probe }) => ({
|
|
2214
|
+
url: image.url.value,
|
|
2215
|
+
declared: `${image.width.value}x${image.height.value}`,
|
|
2216
|
+
// The first frame. An `og:image` is one picture; a container carrying
|
|
2217
|
+
// several is an icon, and `icons.sizes-mismatch` is the rule for those.
|
|
2218
|
+
actual: `${probe.sizes[0].width}x${probe.sizes[0].height}`
|
|
2219
|
+
}));
|
|
2220
|
+
const wrong = observed.filter((entry) => entry.declared !== entry.actual);
|
|
2221
|
+
if (wrong.length === 0) return { status: "pass", observed };
|
|
2222
|
+
return {
|
|
2223
|
+
status: "fail",
|
|
2224
|
+
observed,
|
|
2225
|
+
message: `The declared size is not the image's: ${wrong.map((entry) => `\`${entry.url}\` declares ${entry.declared} and is ${entry.actual}`).join("; ")}.`,
|
|
2226
|
+
origin: { kind: "meta", property: "og:image:width" }
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
};
|
|
2137
2230
|
var iconsUnreachable = {
|
|
2138
2231
|
id: "icons.unreachable",
|
|
2139
2232
|
kind: "boolean",
|
|
@@ -2284,10 +2377,12 @@ var RULES = [
|
|
|
2284
2377
|
ogDescriptionMissing,
|
|
2285
2378
|
ogImageAbsolute,
|
|
2286
2379
|
ogImageAlt,
|
|
2380
|
+
ogImageAltCaption,
|
|
2287
2381
|
ogImageDimensions,
|
|
2288
2382
|
ogImageMissing,
|
|
2289
2383
|
ogImageRatio,
|
|
2290
2384
|
ogImageReachable,
|
|
2385
|
+
ogImageSizesMismatch,
|
|
2291
2386
|
ogLocaleAlternates,
|
|
2292
2387
|
ogLocaleMissing,
|
|
2293
2388
|
ogTitleMissing,
|
|
@@ -2416,6 +2511,17 @@ function extractionFromPage(page) {
|
|
|
2416
2511
|
imageAlt: page.twitter.imageAlt
|
|
2417
2512
|
},
|
|
2418
2513
|
links: linksFromPage(page),
|
|
2514
|
+
...page.hydration ? {
|
|
2515
|
+
hydration: {
|
|
2516
|
+
titleChanged: page.hydration.titleChanged,
|
|
2517
|
+
htmlLangChanged: page.hydration.htmlLangChanged,
|
|
2518
|
+
injectedMetas: page.hydration.clientInjectedMetas,
|
|
2519
|
+
removedMetas: page.hydration.clientRemovedMetas,
|
|
2520
|
+
injectedLinks: page.hydration.clientInjectedLinks,
|
|
2521
|
+
removedLinks: page.hydration.clientRemovedLinks,
|
|
2522
|
+
jsonLdBlocksAdded: page.hydration.jsonLdBlocksAdded
|
|
2523
|
+
}
|
|
2524
|
+
} : {},
|
|
2419
2525
|
jsonLd: page.jsonLd.map(({ index, types, data, parseError, raw }) => ({
|
|
2420
2526
|
index,
|
|
2421
2527
|
types,
|
|
@@ -2741,6 +2847,7 @@ function rowOf(site, url, pathname) {
|
|
|
2741
2847
|
}
|
|
2742
2848
|
function sitemapLocalesByRoute(site) {
|
|
2743
2849
|
const byRoute = /* @__PURE__ */ new Map();
|
|
2850
|
+
const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
|
|
2744
2851
|
for (const entry of site.discovery?.urls ?? []) {
|
|
2745
2852
|
let pathname;
|
|
2746
2853
|
try {
|
|
@@ -2750,6 +2857,7 @@ function sitemapLocalesByRoute(site) {
|
|
|
2750
2857
|
}
|
|
2751
2858
|
const { locale } = splitRoute(pathname);
|
|
2752
2859
|
if (locale === "x-default") continue;
|
|
2860
|
+
if (!axis.has(locale.toLowerCase())) continue;
|
|
2753
2861
|
const route = rowOf(site, entry.loc, pathname);
|
|
2754
2862
|
const set = byRoute.get(route) ?? /* @__PURE__ */ new Set();
|
|
2755
2863
|
set.add(locale.toLowerCase());
|
|
@@ -2764,6 +2872,8 @@ var hreflangMissing = {
|
|
|
2764
2872
|
id: "hreflang.missing",
|
|
2765
2873
|
severity: "error",
|
|
2766
2874
|
summary: "Pages on a multilingual site must advertise their locale alternates",
|
|
2875
|
+
rigor: "vendor-spec",
|
|
2876
|
+
sources: ["google-hreflang", "whatwg-html-link-types"],
|
|
2767
2877
|
appliesTo: isMultilingual,
|
|
2768
2878
|
check: ({ site, issue }) => {
|
|
2769
2879
|
const locales = site.localeAxis.locales.join(", ");
|
|
@@ -2797,62 +2907,57 @@ var hreflangMissing = {
|
|
|
2797
2907
|
);
|
|
2798
2908
|
}
|
|
2799
2909
|
};
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
let pathname;
|
|
2812
|
-
try {
|
|
2813
|
-
pathname = new URL(page.fetch.finalUrl).pathname;
|
|
2814
|
-
} catch {
|
|
2815
|
-
continue;
|
|
2816
|
-
}
|
|
2817
|
-
const route = rowOf(site, page.fetch.finalUrl, pathname);
|
|
2818
|
-
const inSitemap = bySitemap.get(route);
|
|
2819
|
-
if (!inSitemap || inSitemap.size === 0) continue;
|
|
2820
|
-
const onlyInSitemap = sorted(new Set([...inSitemap].filter((l) => !head.has(l))));
|
|
2821
|
-
const onlyInHead = sorted(new Set([...head].filter((l) => !inSitemap.has(l))));
|
|
2822
|
-
if (onlyInSitemap.length === 0 && onlyInHead.length === 0) continue;
|
|
2823
|
-
const parts = [];
|
|
2824
|
-
if (onlyInSitemap.length > 0) {
|
|
2825
|
-
parts.push(
|
|
2826
|
-
`the sitemap lists ${onlyInSitemap.join(", ")} but the \`<head>\` does not advertise ${onlyInSitemap.length > 1 ? "them" : "it"}`
|
|
2827
|
-
);
|
|
2828
|
-
}
|
|
2829
|
-
if (onlyInHead.length > 0) {
|
|
2830
|
-
parts.push(
|
|
2831
|
-
`the \`<head>\` advertises ${onlyInHead.join(", ")} but the sitemap has no entry for ${onlyInHead.length > 1 ? "them" : "it"}`
|
|
2832
|
-
);
|
|
2833
|
-
}
|
|
2834
|
-
findings.push(
|
|
2835
|
-
issue({
|
|
2836
|
-
pageUrl: page.fetch.finalUrl,
|
|
2837
|
-
message: `Route \`${route}\`: ${parts.join("; ")}. Both are derived from the same intent and must not disagree.`,
|
|
2838
|
-
origin: { kind: "link", rel: "alternate" },
|
|
2839
|
-
fix: {
|
|
2840
|
-
title: "Derive both from one locale-availability source",
|
|
2841
|
-
snippet: [
|
|
2842
|
-
"// Compute availability once, feed both the <head> and the sitemap.",
|
|
2843
|
-
"const localesFor = (slug: string) =>",
|
|
2844
|
-
" allDocs.filter((d) => d.slug === slug && !d.draft).map((d) => d.locale);",
|
|
2845
|
-
"",
|
|
2846
|
-
"// generateMetadata(): alternates.languages \u2190 localesFor(slug)",
|
|
2847
|
-
"// sitemap.ts: alternates.languages \u2190 localesFor(slug)"
|
|
2848
|
-
].join("\n"),
|
|
2849
|
-
language: "ts"
|
|
2850
|
-
}
|
|
2851
|
-
})
|
|
2852
|
-
);
|
|
2910
|
+
function coverageGaps(site) {
|
|
2911
|
+
const bySitemap = sitemapLocalesByRoute(site);
|
|
2912
|
+
const gaps = [];
|
|
2913
|
+
for (const page of site.pages) {
|
|
2914
|
+
const head = declaredLocales(page);
|
|
2915
|
+
if (head.size === 0) continue;
|
|
2916
|
+
let pathname;
|
|
2917
|
+
try {
|
|
2918
|
+
pathname = new URL(page.fetch.finalUrl).pathname;
|
|
2919
|
+
} catch {
|
|
2920
|
+
continue;
|
|
2853
2921
|
}
|
|
2854
|
-
|
|
2922
|
+
const route = rowOf(site, page.fetch.finalUrl, pathname);
|
|
2923
|
+
const inSitemap = bySitemap.get(route);
|
|
2924
|
+
if (!inSitemap || inSitemap.size === 0) continue;
|
|
2925
|
+
gaps.push({
|
|
2926
|
+
page,
|
|
2927
|
+
route,
|
|
2928
|
+
onlyInSitemap: sorted(new Set([...inSitemap].filter((l) => !head.has(l))))
|
|
2929
|
+
});
|
|
2855
2930
|
}
|
|
2931
|
+
return gaps;
|
|
2932
|
+
}
|
|
2933
|
+
var ONE_SOURCE_FIX = {
|
|
2934
|
+
title: "Derive both from one locale-availability source",
|
|
2935
|
+
snippet: [
|
|
2936
|
+
"// Compute availability once, feed both the <head> and the sitemap.",
|
|
2937
|
+
"const localesFor = (slug: string) =>",
|
|
2938
|
+
" allDocs.filter((d) => d.slug === slug && !d.draft).map((d) => d.locale);",
|
|
2939
|
+
"",
|
|
2940
|
+
"// generateMetadata(): alternates.languages \u2190 localesFor(slug)",
|
|
2941
|
+
"// sitemap.ts: alternates.languages \u2190 localesFor(slug)"
|
|
2942
|
+
].join("\n"),
|
|
2943
|
+
language: "ts"
|
|
2944
|
+
};
|
|
2945
|
+
var bothApply = (site) => isMultilingual(site) && (site.discovery?.urls.length ?? 0) > 0;
|
|
2946
|
+
var hreflangClusterIncomplete = {
|
|
2947
|
+
id: "hreflang.cluster-incomplete",
|
|
2948
|
+
severity: "warning",
|
|
2949
|
+
summary: "Every locale the sitemap publishes must appear in the route's `hreflang` cluster",
|
|
2950
|
+
rigor: "vendor-spec",
|
|
2951
|
+
sources: ["google-hreflang"],
|
|
2952
|
+
appliesTo: bothApply,
|
|
2953
|
+
check: ({ site, issue }) => coverageGaps(site).filter((gap) => gap.onlyInSitemap.length > 0).map(
|
|
2954
|
+
({ page, route, onlyInSitemap }) => issue({
|
|
2955
|
+
pageUrl: page.fetch.finalUrl,
|
|
2956
|
+
message: `Route \`${route}\`: the sitemap lists ${onlyInSitemap.join(", ")} but the \`<head>\` does not advertise ${onlyInSitemap.length > 1 ? "them" : "it"}. The site publishes ${onlyInSitemap.length > 1 ? "those versions" : "that version"} and leaves ${onlyInSitemap.length > 1 ? "them" : "it"} outside the cluster, so ${onlyInSitemap.length > 1 ? "they compete" : "it competes"} with this page instead of consolidating with it.`,
|
|
2957
|
+
origin: { kind: "link", rel: "alternate" },
|
|
2958
|
+
fix: ONE_SOURCE_FIX
|
|
2959
|
+
})
|
|
2960
|
+
)
|
|
2856
2961
|
};
|
|
2857
2962
|
function metaRobotsTokens(page) {
|
|
2858
2963
|
const raw = page.meta.robots?.value ?? "";
|
|
@@ -3156,6 +3261,84 @@ var sitemapEntryInvalidUrl = {
|
|
|
3156
3261
|
});
|
|
3157
3262
|
}
|
|
3158
3263
|
};
|
|
3264
|
+
var DOCUMENT_URL_LIMIT = 5e4;
|
|
3265
|
+
var DOCUMENT_BYTE_LIMIT = 52428800;
|
|
3266
|
+
function declaredCount(doc) {
|
|
3267
|
+
return doc.kind === "index" ? doc.childLocs.length : doc.urlCount;
|
|
3268
|
+
}
|
|
3269
|
+
function oversizedDocuments(site) {
|
|
3270
|
+
return (site.discovery?.documents ?? []).filter(
|
|
3271
|
+
(doc) => doc.kind !== "unparsable" && (declaredCount(doc) > DOCUMENT_URL_LIMIT || doc.byteLength > DOCUMENT_BYTE_LIMIT)
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
var sitemapLimitsExceeded = {
|
|
3275
|
+
id: "sitemap.limits.exceeded",
|
|
3276
|
+
severity: "error",
|
|
3277
|
+
summary: "No single sitemap document may exceed 50,000 entries or 50 MB",
|
|
3278
|
+
rigor: "spec-required",
|
|
3279
|
+
sources: ["sitemaps-protocol"],
|
|
3280
|
+
appliesTo: (site) => oversizedDocuments(site).length > 0,
|
|
3281
|
+
check: ({ site, issue }) => oversizedDocuments(site).map((doc) => {
|
|
3282
|
+
const count2 = declaredCount(doc);
|
|
3283
|
+
const noun = doc.kind === "index" ? "child sitemaps" : "URLs";
|
|
3284
|
+
const over = [];
|
|
3285
|
+
if (count2 > DOCUMENT_URL_LIMIT) {
|
|
3286
|
+
over.push(`${count2.toLocaleString("en-US")} ${noun} against a ceiling of 50,000`);
|
|
3287
|
+
}
|
|
3288
|
+
if (doc.byteLength > DOCUMENT_BYTE_LIMIT) {
|
|
3289
|
+
over.push(
|
|
3290
|
+
`${(doc.byteLength / 1048576).toFixed(1)} MB uncompressed against a ceiling of 50 MB`
|
|
3291
|
+
);
|
|
3292
|
+
}
|
|
3293
|
+
return issue({
|
|
3294
|
+
pageUrl: doc.url,
|
|
3295
|
+
message: `This sitemap document declares ${over.join(" and ")}. A consumer may stop reading at the limit, so everything past it is published in name only \u2014 split the document and reference the parts from an index.`,
|
|
3296
|
+
origin: { kind: "computed" }
|
|
3297
|
+
});
|
|
3298
|
+
})
|
|
3299
|
+
};
|
|
3300
|
+
function outOfScopeEntries(site) {
|
|
3301
|
+
const out = [];
|
|
3302
|
+
for (const entry of site.discovery?.urls ?? []) {
|
|
3303
|
+
if (!entry.documentUrl) continue;
|
|
3304
|
+
let scope;
|
|
3305
|
+
let loc;
|
|
3306
|
+
try {
|
|
3307
|
+
scope = new URL(entry.documentUrl);
|
|
3308
|
+
loc = new URL(entry.loc);
|
|
3309
|
+
} catch {
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
const prefix = scope.pathname.replace(/[^/]*$/, "");
|
|
3313
|
+
if (prefix === "/") continue;
|
|
3314
|
+
if (loc.origin !== scope.origin) continue;
|
|
3315
|
+
if (loc.pathname.startsWith(prefix)) continue;
|
|
3316
|
+
out.push({ loc: entry.loc, from: entry.documentUrl });
|
|
3317
|
+
}
|
|
3318
|
+
return out;
|
|
3319
|
+
}
|
|
3320
|
+
var sitemapEntryOutOfScope = {
|
|
3321
|
+
id: "sitemap.entry.out-of-scope",
|
|
3322
|
+
severity: "error",
|
|
3323
|
+
summary: "A sitemap may only list URLs under its own directory",
|
|
3324
|
+
rigor: "spec-required",
|
|
3325
|
+
sources: ["sitemaps-protocol"],
|
|
3326
|
+
appliesTo: (site) => outOfScopeEntries(site).length > 0,
|
|
3327
|
+
check: ({ site, issue }) => {
|
|
3328
|
+
const bad = outOfScopeEntries(site);
|
|
3329
|
+
const byDocument = /* @__PURE__ */ new Map();
|
|
3330
|
+
for (const { loc, from } of bad) {
|
|
3331
|
+
byDocument.set(from, [...byDocument.get(from) ?? [], loc]);
|
|
3332
|
+
}
|
|
3333
|
+
return [...byDocument].map(
|
|
3334
|
+
([from, locs]) => issue({
|
|
3335
|
+
pageUrl: from,
|
|
3336
|
+
message: `${locs.length} entr${locs.length === 1 ? "y is" : "ies are"} outside this sitemap's directory \`${new URL(from).pathname.replace(/[^/]*$/, "")}\`: ${sample(locs)}. A sitemap only speaks for the path it is served from, so a consumer may drop ${locs.length === 1 ? "it" : "them"} \u2014 serve the document from the root, or move the entries into a sitemap that covers them.`,
|
|
3337
|
+
origin: { kind: "computed" }
|
|
3338
|
+
})
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
};
|
|
3159
3342
|
var sitemapEntryCrossHost = {
|
|
3160
3343
|
id: "sitemap.entry.cross-host",
|
|
3161
3344
|
severity: "error",
|
|
@@ -3485,7 +3668,7 @@ function isAbsolute(value) {
|
|
|
3485
3668
|
}
|
|
3486
3669
|
var SITE_RULES = [
|
|
3487
3670
|
hreflangMissing,
|
|
3488
|
-
|
|
3671
|
+
hreflangClusterIncomplete,
|
|
3489
3672
|
iconsIcoMissing,
|
|
3490
3673
|
robotsBlocksPage,
|
|
3491
3674
|
robotsBlocksSite,
|
|
@@ -3501,12 +3684,14 @@ var SITE_RULES = [
|
|
|
3501
3684
|
sitemapEntryInvalidUrl,
|
|
3502
3685
|
sitemapEntryNoindex,
|
|
3503
3686
|
sitemapEntryNonCanonical,
|
|
3687
|
+
sitemapEntryOutOfScope,
|
|
3504
3688
|
sitemapEntryProtocolMismatch,
|
|
3505
3689
|
sitemapEntryRedirects,
|
|
3506
3690
|
sitemapEntryUnreachable,
|
|
3507
3691
|
sitemapFieldInvalid,
|
|
3508
3692
|
sitemapIndexChildError,
|
|
3509
3693
|
sitemapLastmodInvalid,
|
|
3694
|
+
sitemapLimitsExceeded,
|
|
3510
3695
|
sitemapMissing,
|
|
3511
3696
|
sitemapOrphans,
|
|
3512
3697
|
sitemapUnparsable
|
|
@@ -3778,9 +3963,23 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3778
3963
|
diagnostics.atWellKnownPath = isWellKnownPath(candidate.url);
|
|
3779
3964
|
const collected = [];
|
|
3780
3965
|
const seen = /* @__PURE__ */ new Set();
|
|
3966
|
+
const documents = [];
|
|
3781
3967
|
let truncated = false;
|
|
3968
|
+
documents.push({
|
|
3969
|
+
url: candidate.url,
|
|
3970
|
+
status: doc.status,
|
|
3971
|
+
byteLength: doc.byteLength,
|
|
3972
|
+
gzipped: doc.gzipped,
|
|
3973
|
+
kind: parsed.kind === "index" ? "index" : "urlset",
|
|
3974
|
+
childLocs: parsed.kind === "index" ? [...parsed.sitemaps] : [],
|
|
3975
|
+
// What the document declares, not what survived dedupe and the cap: the
|
|
3976
|
+
// ceiling is on the file's contents, and a post-dedupe count would answer
|
|
3977
|
+
// a question the protocol never asked.
|
|
3978
|
+
urlCount: parsed.kind === "urlset" ? parsed.urls.length : 0,
|
|
3979
|
+
declaredInRobots: candidate.declaredInRobots
|
|
3980
|
+
});
|
|
3782
3981
|
if (parsed.kind === "urlset") {
|
|
3783
|
-
truncated = pushEntries(collected, seen, parsed.urls, maxUrls);
|
|
3982
|
+
truncated = pushEntries(collected, seen, parsed.urls, maxUrls, candidate.url);
|
|
3784
3983
|
} else {
|
|
3785
3984
|
diagnostics.isIndex = true;
|
|
3786
3985
|
const children = parsed.sitemaps.slice(0, maxSitemaps);
|
|
@@ -3798,17 +3997,44 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3798
3997
|
}
|
|
3799
3998
|
const childDoc = await fetchDoc(childUrl, options);
|
|
3800
3999
|
if (!childDoc.body) {
|
|
4000
|
+
documents.push({
|
|
4001
|
+
url: childUrl,
|
|
4002
|
+
status: childDoc.status,
|
|
4003
|
+
byteLength: childDoc.byteLength,
|
|
4004
|
+
gzipped: childDoc.gzipped,
|
|
4005
|
+
kind: "unparsable",
|
|
4006
|
+
childLocs: [],
|
|
4007
|
+
urlCount: 0,
|
|
4008
|
+
declaredInRobots: declaredUrls.includes(childUrl),
|
|
4009
|
+
parentUrl: candidate.url
|
|
4010
|
+
});
|
|
3801
4011
|
diagnostics.childSitemapErrors += 1;
|
|
3802
4012
|
diagnostics.warnings.push(`Child sitemap unreachable: ${childUrl}`);
|
|
3803
4013
|
continue;
|
|
3804
4014
|
}
|
|
3805
4015
|
const childParsed = parseSitemap(childDoc.body);
|
|
4016
|
+
documents.push({
|
|
4017
|
+
url: childUrl,
|
|
4018
|
+
status: childDoc.status,
|
|
4019
|
+
byteLength: childDoc.byteLength,
|
|
4020
|
+
gzipped: childDoc.gzipped,
|
|
4021
|
+
// A child that is itself an index is recorded as one. This run still
|
|
4022
|
+
// treats it as an error below, because that is what it has always
|
|
4023
|
+
// done and replacing that verdict needs a source no specification
|
|
4024
|
+
// provides — see `docs/sitemap-robots-plan.md` §4.3. The tree now
|
|
4025
|
+
// says which of the two it was, which is the part that was lost.
|
|
4026
|
+
kind: childParsed.kind === "unknown" ? "unparsable" : childParsed.kind,
|
|
4027
|
+
childLocs: childParsed.kind === "index" ? [...childParsed.sitemaps] : [],
|
|
4028
|
+
urlCount: childParsed.kind === "urlset" ? childParsed.urls.length : 0,
|
|
4029
|
+
declaredInRobots: declaredUrls.includes(childUrl),
|
|
4030
|
+
parentUrl: candidate.url
|
|
4031
|
+
});
|
|
3806
4032
|
if (childParsed.kind !== "urlset") {
|
|
3807
4033
|
diagnostics.childSitemapErrors += 1;
|
|
3808
4034
|
diagnostics.warnings.push(`Child sitemap not a urlset: ${childUrl}`);
|
|
3809
4035
|
continue;
|
|
3810
4036
|
}
|
|
3811
|
-
if (pushEntries(collected, seen, childParsed.urls, maxUrls)) truncated = true;
|
|
4037
|
+
if (pushEntries(collected, seen, childParsed.urls, maxUrls, childUrl)) truncated = true;
|
|
3812
4038
|
}
|
|
3813
4039
|
}
|
|
3814
4040
|
diagnostics.urlCount = collected.length;
|
|
@@ -3820,6 +4046,7 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3820
4046
|
baseUrl,
|
|
3821
4047
|
source: candidate.source,
|
|
3822
4048
|
urls: collected,
|
|
4049
|
+
documents,
|
|
3823
4050
|
diagnostics,
|
|
3824
4051
|
truncated
|
|
3825
4052
|
};
|
|
@@ -3828,7 +4055,15 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3828
4055
|
return crawlSite(baseUrl, origin, options, diagnostics);
|
|
3829
4056
|
}
|
|
3830
4057
|
diagnostics.warnings.push("No sitemap found.");
|
|
3831
|
-
return {
|
|
4058
|
+
return {
|
|
4059
|
+
origin,
|
|
4060
|
+
baseUrl,
|
|
4061
|
+
source: "well-known",
|
|
4062
|
+
urls: [],
|
|
4063
|
+
documents: [],
|
|
4064
|
+
diagnostics,
|
|
4065
|
+
truncated: false
|
|
4066
|
+
};
|
|
3832
4067
|
}
|
|
3833
4068
|
async function crawlSite(baseUrl, origin, options, diagnostics) {
|
|
3834
4069
|
const result = await crawl({
|
|
@@ -3852,16 +4087,20 @@ async function crawlSite(baseUrl, origin, options, diagnostics) {
|
|
|
3852
4087
|
baseUrl,
|
|
3853
4088
|
source: "crawl",
|
|
3854
4089
|
urls,
|
|
4090
|
+
// No document declared these: the crawl found them by following links. An
|
|
4091
|
+
// empty tree is the honest answer, and the rules that read it are the ones
|
|
4092
|
+
// that judge documents — which this run has none of.
|
|
4093
|
+
documents: [],
|
|
3855
4094
|
diagnostics,
|
|
3856
4095
|
truncated: result.truncated
|
|
3857
4096
|
};
|
|
3858
4097
|
}
|
|
3859
|
-
function pushEntries(out, seen, entries, cap) {
|
|
4098
|
+
function pushEntries(out, seen, entries, cap, documentUrl) {
|
|
3860
4099
|
for (const entry of entries) {
|
|
3861
4100
|
if (out.length >= cap) return true;
|
|
3862
4101
|
if (seen.has(entry.loc)) continue;
|
|
3863
4102
|
seen.add(entry.loc);
|
|
3864
|
-
out.push(entry);
|
|
4103
|
+
out.push({ ...entry, documentUrl });
|
|
3865
4104
|
}
|
|
3866
4105
|
return out.length >= cap;
|
|
3867
4106
|
}
|
|
@@ -3890,26 +4129,29 @@ async function fetchDoc(url, options) {
|
|
|
3890
4129
|
accept: "application/xml,text/xml,application/gzip;q=0.9,*/*;q=0.5"
|
|
3891
4130
|
}
|
|
3892
4131
|
});
|
|
3893
|
-
if (!res.ok) return { status: res.status };
|
|
4132
|
+
if (!res.ok) return { status: res.status, byteLength: 0, gzipped: false };
|
|
3894
4133
|
const encoding = res.headers.get("content-encoding")?.toLowerCase() ?? "";
|
|
3895
4134
|
const looksGzip = url.toLowerCase().endsWith(".gz") || encoding.includes("gzip");
|
|
3896
4135
|
if (looksGzip) {
|
|
3897
4136
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
3898
4137
|
try {
|
|
3899
|
-
return
|
|
4138
|
+
return withLength(res.status, gunzipSync(buf).toString("utf8"), true);
|
|
3900
4139
|
} catch {
|
|
3901
|
-
return
|
|
4140
|
+
return withLength(res.status, buf.toString("utf8"), true);
|
|
3902
4141
|
}
|
|
3903
4142
|
}
|
|
3904
|
-
return
|
|
4143
|
+
return withLength(res.status, await res.text(), false);
|
|
3905
4144
|
} catch (err) {
|
|
3906
4145
|
const reason = err instanceof Error ? err.message : String(err);
|
|
3907
|
-
return { status: 0, error: reason || "network error" };
|
|
4146
|
+
return { status: 0, error: reason || "network error", byteLength: 0, gzipped: false };
|
|
3908
4147
|
} finally {
|
|
3909
4148
|
cleanup();
|
|
3910
4149
|
restoreTls();
|
|
3911
4150
|
}
|
|
3912
4151
|
}
|
|
4152
|
+
function withLength(status, body, gzipped) {
|
|
4153
|
+
return { status, body, byteLength: Buffer.byteLength(body, "utf8"), gzipped };
|
|
4154
|
+
}
|
|
3913
4155
|
function relaxTlsIfRequested2(enabled) {
|
|
3914
4156
|
if (!enabled) return () => void 0;
|
|
3915
4157
|
const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
@@ -4256,7 +4498,7 @@ function schemeOf(url) {
|
|
|
4256
4498
|
}
|
|
4257
4499
|
}
|
|
4258
4500
|
function defaultSleep(ms) {
|
|
4259
|
-
return new Promise((
|
|
4501
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
4260
4502
|
}
|
|
4261
4503
|
|
|
4262
4504
|
// src/lib/core/sitemap/entries.ts
|
|
@@ -4585,13 +4827,58 @@ async function readHead(res) {
|
|
|
4585
4827
|
} finally {
|
|
4586
4828
|
await reader.cancel().catch(() => void 0);
|
|
4587
4829
|
}
|
|
4588
|
-
return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
|
|
4830
|
+
return chunks.length > 0 ? Buffer.concat(chunks).subarray(0, HEAD_BYTES) : void 0;
|
|
4589
4831
|
}
|
|
4590
4832
|
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
4833
|
+
var JPEG_SOI = 65496;
|
|
4834
|
+
var JPEG_FRAME_MARKERS = /* @__PURE__ */ new Set([
|
|
4835
|
+
192,
|
|
4836
|
+
193,
|
|
4837
|
+
194,
|
|
4838
|
+
195,
|
|
4839
|
+
197,
|
|
4840
|
+
198,
|
|
4841
|
+
199,
|
|
4842
|
+
201,
|
|
4843
|
+
202,
|
|
4844
|
+
203,
|
|
4845
|
+
205,
|
|
4846
|
+
206,
|
|
4847
|
+
207
|
|
4848
|
+
]);
|
|
4849
|
+
function decodeJpegSize(head) {
|
|
4850
|
+
let at = 2;
|
|
4851
|
+
while (at + 3 < head.length) {
|
|
4852
|
+
if (head[at] !== 255) return void 0;
|
|
4853
|
+
const marker = head[at + 1];
|
|
4854
|
+
if (marker === 255) {
|
|
4855
|
+
at += 1;
|
|
4856
|
+
continue;
|
|
4857
|
+
}
|
|
4858
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
4859
|
+
at += 2;
|
|
4860
|
+
continue;
|
|
4861
|
+
}
|
|
4862
|
+
if (marker === 217 || marker === 218) return void 0;
|
|
4863
|
+
const length = head.readUInt16BE(at + 2);
|
|
4864
|
+
if (length < 2) return void 0;
|
|
4865
|
+
if (JPEG_FRAME_MARKERS.has(marker)) {
|
|
4866
|
+
if (at + 9 > head.length) return void 0;
|
|
4867
|
+
return { width: head.readUInt16BE(at + 7), height: head.readUInt16BE(at + 5) };
|
|
4868
|
+
}
|
|
4869
|
+
at += 2 + length;
|
|
4870
|
+
}
|
|
4871
|
+
return void 0;
|
|
4872
|
+
}
|
|
4591
4873
|
function decodeSizes(head) {
|
|
4592
4874
|
if (head.length >= 24 && head.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
4593
4875
|
return [{ width: head.readUInt32BE(16), height: head.readUInt32BE(20) }];
|
|
4594
4876
|
}
|
|
4877
|
+
if (head.length >= 4 && head.readUInt16BE(0) === JPEG_SOI) {
|
|
4878
|
+
const size = decodeJpegSize(head);
|
|
4879
|
+
if (size) return [size];
|
|
4880
|
+
return void 0;
|
|
4881
|
+
}
|
|
4595
4882
|
if (head.length >= 6 && head.readUInt16LE(0) === 0 && head.readUInt16LE(2) === 1) {
|
|
4596
4883
|
const count2 = head.readUInt16LE(4);
|
|
4597
4884
|
if (count2 === 0 || head.length < 6 + 16 * count2) return void 0;
|
|
@@ -4794,6 +5081,89 @@ var PROSE_RULES = [
|
|
|
4794
5081
|
];
|
|
4795
5082
|
var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
|
|
4796
5083
|
|
|
5084
|
+
// src/lib/rules/site-prose.ts
|
|
5085
|
+
var hreflangSitemapMismatch = {
|
|
5086
|
+
id: "hreflang.sitemap-mismatch",
|
|
5087
|
+
summary: "`<head>` alternates advertise a locale the sitemap does not list",
|
|
5088
|
+
prose: "Your `<head>` advertises a translation your sitemap does not list. Is the sitemap missing an entry, or is the alternate pointing at a page you did not mean to publish?",
|
|
5089
|
+
why: "The two are derived from one intent by different code paths, so a disagreement means one of them is wrong. No specification says which: the declaration methods are equivalent, and a page can legitimately be cross-linked and kept out of the sitemap.",
|
|
5090
|
+
appliesTo: (site) => site.localeAxis.multilingual && (site.discovery?.urls.length ?? 0) > 0,
|
|
5091
|
+
ask: (site) => {
|
|
5092
|
+
const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
|
|
5093
|
+
const bySitemap = /* @__PURE__ */ new Map();
|
|
5094
|
+
for (const entry of site.discovery?.urls ?? []) {
|
|
5095
|
+
let pathname;
|
|
5096
|
+
try {
|
|
5097
|
+
pathname = new URL(entry.loc).pathname;
|
|
5098
|
+
} catch {
|
|
5099
|
+
continue;
|
|
5100
|
+
}
|
|
5101
|
+
const { locale } = splitRoute(pathname);
|
|
5102
|
+
if (locale === "x-default") continue;
|
|
5103
|
+
if (!axis.has(locale.toLowerCase())) continue;
|
|
5104
|
+
const route = site.clusterRouteOf?.(entry.loc) ?? splitRoute(pathname).route;
|
|
5105
|
+
const set = bySitemap.get(route) ?? /* @__PURE__ */ new Set();
|
|
5106
|
+
set.add(locale.toLowerCase());
|
|
5107
|
+
bySitemap.set(route, set);
|
|
5108
|
+
}
|
|
5109
|
+
const drafts = [];
|
|
5110
|
+
for (const page of site.pages) {
|
|
5111
|
+
const head = /* @__PURE__ */ new Set();
|
|
5112
|
+
for (const alt of page.links.alternates) {
|
|
5113
|
+
if (alt.isXDefault) continue;
|
|
5114
|
+
const tag = alt.hreflang?.trim().toLowerCase();
|
|
5115
|
+
if (tag) head.add(tag);
|
|
5116
|
+
}
|
|
5117
|
+
if (head.size === 0) continue;
|
|
5118
|
+
let pathname;
|
|
5119
|
+
try {
|
|
5120
|
+
pathname = new URL(page.fetch.finalUrl).pathname;
|
|
5121
|
+
} catch {
|
|
5122
|
+
continue;
|
|
5123
|
+
}
|
|
5124
|
+
const route = site.clusterRouteOf?.(page.fetch.finalUrl) ?? splitRoute(pathname).route;
|
|
5125
|
+
const inSitemap = bySitemap.get(route);
|
|
5126
|
+
if (!inSitemap || inSitemap.size === 0) continue;
|
|
5127
|
+
const unlisted = [...head].filter((l) => !inSitemap.has(l)).sort();
|
|
5128
|
+
if (unlisted.length === 0) continue;
|
|
5129
|
+
drafts.push({
|
|
5130
|
+
pageUrl: page.fetch.finalUrl,
|
|
5131
|
+
evidence: {
|
|
5132
|
+
route,
|
|
5133
|
+
// Both sides, verbatim and sorted, so an agent can see the shape of
|
|
5134
|
+
// the disagreement rather than take our word for its size.
|
|
5135
|
+
headAdvertises: [...head].sort(),
|
|
5136
|
+
sitemapLists: [...inSitemap].sort(),
|
|
5137
|
+
advertisedButUnlisted: unlisted
|
|
5138
|
+
}
|
|
5139
|
+
});
|
|
5140
|
+
}
|
|
5141
|
+
return drafts;
|
|
5142
|
+
}
|
|
5143
|
+
};
|
|
5144
|
+
var SITE_PROSE_RULES = [hreflangSitemapMismatch];
|
|
5145
|
+
function collectSiteAdvisories(site, rules = SITE_PROSE_RULES) {
|
|
5146
|
+
const out = [];
|
|
5147
|
+
for (const rule of rules) {
|
|
5148
|
+
if (rule.appliesTo && !rule.appliesTo(site)) continue;
|
|
5149
|
+
for (const draft of rule.ask(site)) {
|
|
5150
|
+
out.push({
|
|
5151
|
+
ruleId: rule.id,
|
|
5152
|
+
kind: "prose",
|
|
5153
|
+
prose: rule.prose,
|
|
5154
|
+
// `null` where nothing supports the question, which is exactly the
|
|
5155
|
+
// state this rule is in and the reason it stopped being a verdict.
|
|
5156
|
+
rigor: rule.rigor ?? null,
|
|
5157
|
+
sources: rule.sources ?? [],
|
|
5158
|
+
evidence: draft.evidence,
|
|
5159
|
+
verdict: "needs-judgment",
|
|
5160
|
+
pageUrl: draft.pageUrl
|
|
5161
|
+
});
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
5164
|
+
return out;
|
|
5165
|
+
}
|
|
5166
|
+
|
|
4797
5167
|
// src/lib/core/links/extract.ts
|
|
4798
5168
|
import * as cheerio3 from "cheerio";
|
|
4799
5169
|
var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
|
|
@@ -5027,22 +5397,22 @@ function hostOf(url) {
|
|
|
5027
5397
|
return url;
|
|
5028
5398
|
}
|
|
5029
5399
|
}
|
|
5030
|
-
function mapWithHostCaps(items, opts, worker,
|
|
5400
|
+
function mapWithHostCaps(items, opts, worker, hostOf3) {
|
|
5031
5401
|
const concurrency = Math.max(1, opts.concurrency);
|
|
5032
5402
|
const perHost = opts.perHost;
|
|
5033
|
-
return new Promise((
|
|
5403
|
+
return new Promise((resolve2) => {
|
|
5034
5404
|
if (items.length === 0) {
|
|
5035
|
-
|
|
5405
|
+
resolve2();
|
|
5036
5406
|
return;
|
|
5037
5407
|
}
|
|
5038
|
-
const pending = items.map((item) => ({ item, host:
|
|
5408
|
+
const pending = items.map((item) => ({ item, host: hostOf3(item) }));
|
|
5039
5409
|
const hostActive = /* @__PURE__ */ new Map();
|
|
5040
5410
|
let inflight = 0;
|
|
5041
5411
|
let settled = false;
|
|
5042
5412
|
const finish = () => {
|
|
5043
5413
|
if (!settled) {
|
|
5044
5414
|
settled = true;
|
|
5045
|
-
|
|
5415
|
+
resolve2();
|
|
5046
5416
|
}
|
|
5047
5417
|
};
|
|
5048
5418
|
const pump = () => {
|
|
@@ -5299,7 +5669,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5299
5669
|
signal: options.signal,
|
|
5300
5670
|
timeoutMs: options.timeoutMs
|
|
5301
5671
|
}).catch(() => void 0);
|
|
5302
|
-
const
|
|
5672
|
+
const favicon2 = await probeFavicon(origin, {
|
|
5303
5673
|
signal: options.signal,
|
|
5304
5674
|
timeoutMs: options.timeoutMs
|
|
5305
5675
|
}).catch(() => void 0);
|
|
@@ -5359,11 +5729,15 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5359
5729
|
const seoIssues = [];
|
|
5360
5730
|
const conformanceRows = [];
|
|
5361
5731
|
const advisories = [];
|
|
5732
|
+
const extractions = [];
|
|
5362
5733
|
const ruleById = new Map(rules.map((rule) => [rule.id, rule]));
|
|
5363
5734
|
for (const page of htmlPages) {
|
|
5364
5735
|
const pageUrl = page.fetch.finalUrl;
|
|
5365
5736
|
const extraction = extractionFromPage(page);
|
|
5366
5737
|
const evaluation = evaluateRules(extraction, rules);
|
|
5738
|
+
if (options.extractions) {
|
|
5739
|
+
extractions.push(extraction);
|
|
5740
|
+
}
|
|
5367
5741
|
if (options.conformance) {
|
|
5368
5742
|
conformanceRows.push({ pageUrl, findings: evaluation.findings });
|
|
5369
5743
|
}
|
|
@@ -5476,7 +5850,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5476
5850
|
localeAxis,
|
|
5477
5851
|
discovery,
|
|
5478
5852
|
robots,
|
|
5479
|
-
favicon,
|
|
5853
|
+
favicon: favicon2,
|
|
5480
5854
|
sitemapEntries,
|
|
5481
5855
|
// Same index the matrix was built from, so a rule that groups by route
|
|
5482
5856
|
// and the grid that reports holes cannot disagree about which URLs are
|
|
@@ -5501,6 +5875,14 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5501
5875
|
sources: draft.sources
|
|
5502
5876
|
};
|
|
5503
5877
|
});
|
|
5878
|
+
if (options.advisories) {
|
|
5879
|
+
for (const advisory of collectSiteAdvisories(siteContext)) {
|
|
5880
|
+
advisories.push({
|
|
5881
|
+
...advisory,
|
|
5882
|
+
id: fingerprint("advisory", advisory.ruleId, routeKey(advisory.pageUrl))
|
|
5883
|
+
});
|
|
5884
|
+
}
|
|
5885
|
+
}
|
|
5504
5886
|
const brokenLinks = [];
|
|
5505
5887
|
for (const { pageUrl, broken } of linkReport.brokenByPage) {
|
|
5506
5888
|
for (const check of broken) {
|
|
@@ -5576,6 +5958,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5576
5958
|
// made.
|
|
5577
5959
|
...options.conformance ? { conformance: buildConformance(rules, conformanceRows) } : {},
|
|
5578
5960
|
...options.advisories ? { advisories } : {},
|
|
5961
|
+
...options.extractions ? { extractions } : {},
|
|
5579
5962
|
diagnostics: {
|
|
5580
5963
|
pagesCrawled: pages.length,
|
|
5581
5964
|
pagesScanned: linkReport.pagesScanned,
|
|
@@ -5637,6 +6020,10 @@ function syntheticDiscovery(origin, baseUrl, pages, truncated) {
|
|
|
5637
6020
|
baseUrl,
|
|
5638
6021
|
source: "crawl",
|
|
5639
6022
|
urls: pages.map((p) => ({ loc: p.fetch.finalUrl })),
|
|
6023
|
+
// Crawl-derived: no sitemap document was read, so the tree is empty rather
|
|
6024
|
+
// than absent. The document rules ask "what did each document declare?" and
|
|
6025
|
+
// must see that nothing declared anything.
|
|
6026
|
+
documents: [],
|
|
5640
6027
|
diagnostics: {
|
|
5641
6028
|
found: false,
|
|
5642
6029
|
status: 0,
|
|
@@ -6003,6 +6390,622 @@ function rollupRows(issues, c) {
|
|
|
6003
6390
|
return lines;
|
|
6004
6391
|
}
|
|
6005
6392
|
|
|
6393
|
+
// src/report/render-preview.ts
|
|
6394
|
+
var HEAD_PREFIXES = ["og.", "twitter.", "icons.", "title.", "description.", "canonical."];
|
|
6395
|
+
var RATIO_IDEAL2 = [1.7, 2.1];
|
|
6396
|
+
var RATIO_ACCEPTABLE2 = [1, 3];
|
|
6397
|
+
var ESCAPES = {
|
|
6398
|
+
"&": "&",
|
|
6399
|
+
"<": "<",
|
|
6400
|
+
">": ">",
|
|
6401
|
+
'"': """,
|
|
6402
|
+
"'": "'"
|
|
6403
|
+
};
|
|
6404
|
+
function esc(value) {
|
|
6405
|
+
return value.replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c);
|
|
6406
|
+
}
|
|
6407
|
+
function codeSpans(escaped) {
|
|
6408
|
+
return escaped.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
6409
|
+
}
|
|
6410
|
+
function clip(value, max = 300) {
|
|
6411
|
+
return value.length > max ? `${value.slice(0, max)}\u2026` : value;
|
|
6412
|
+
}
|
|
6413
|
+
function prettyJson(value) {
|
|
6414
|
+
try {
|
|
6415
|
+
const out = JSON.stringify(value, null, 2);
|
|
6416
|
+
if (out === void 0) return void 0;
|
|
6417
|
+
return out.length > 2e4 ? `${out.slice(0, 2e4)}
|
|
6418
|
+
\u2026 truncated` : out;
|
|
6419
|
+
} catch {
|
|
6420
|
+
return void 0;
|
|
6421
|
+
}
|
|
6422
|
+
}
|
|
6423
|
+
function safeUrl(value) {
|
|
6424
|
+
if (!value) return void 0;
|
|
6425
|
+
try {
|
|
6426
|
+
const url = new URL(value);
|
|
6427
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : void 0;
|
|
6428
|
+
} catch {
|
|
6429
|
+
return void 0;
|
|
6430
|
+
}
|
|
6431
|
+
}
|
|
6432
|
+
function hostOf2(value) {
|
|
6433
|
+
try {
|
|
6434
|
+
return new URL(value).host;
|
|
6435
|
+
} catch {
|
|
6436
|
+
return value;
|
|
6437
|
+
}
|
|
6438
|
+
}
|
|
6439
|
+
function pathOf(value) {
|
|
6440
|
+
try {
|
|
6441
|
+
const { pathname, search } = new URL(value);
|
|
6442
|
+
return `${pathname}${search}`;
|
|
6443
|
+
} catch {
|
|
6444
|
+
return value;
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
function originLabel(origin) {
|
|
6448
|
+
switch (origin.kind) {
|
|
6449
|
+
case "title":
|
|
6450
|
+
return "<title>";
|
|
6451
|
+
case "meta":
|
|
6452
|
+
if (origin.property) return origin.property;
|
|
6453
|
+
if (origin.name) return origin.name;
|
|
6454
|
+
if (origin.httpEquiv) return `http-equiv=${origin.httpEquiv}`;
|
|
6455
|
+
return "<meta>";
|
|
6456
|
+
case "link":
|
|
6457
|
+
return `<link rel="${origin.rel}">`;
|
|
6458
|
+
case "html":
|
|
6459
|
+
return `<html ${origin.attribute}>`;
|
|
6460
|
+
case "json-ld":
|
|
6461
|
+
return `JSON-LD #${origin.index}`;
|
|
6462
|
+
case "header":
|
|
6463
|
+
return `header: ${origin.name}`;
|
|
6464
|
+
case "computed":
|
|
6465
|
+
return "computed";
|
|
6466
|
+
}
|
|
6467
|
+
}
|
|
6468
|
+
function resolve(...facts) {
|
|
6469
|
+
for (const fact of facts) {
|
|
6470
|
+
if (fact && fact.value.trim() !== "") {
|
|
6471
|
+
return { value: fact.value, from: originLabel(fact.origin) };
|
|
6472
|
+
}
|
|
6473
|
+
}
|
|
6474
|
+
return void 0;
|
|
6475
|
+
}
|
|
6476
|
+
function usable(size) {
|
|
6477
|
+
return size && size.width > 0 && size.height > 0 ? size : void 0;
|
|
6478
|
+
}
|
|
6479
|
+
function imageOf(extraction) {
|
|
6480
|
+
const og = extraction.openGraph.images[0];
|
|
6481
|
+
const ogUrl = og?.url.value.trim() ? og.url.value : void 0;
|
|
6482
|
+
const declared = ogUrl ?? extraction.twitter.image?.value;
|
|
6483
|
+
if (!declared || declared.trim() === "") return void 0;
|
|
6484
|
+
const probe = extraction.assets?.[declared.trim()];
|
|
6485
|
+
const declaredSize = usable(
|
|
6486
|
+
og?.width && og.height ? { width: og.width.value, height: og.height.value } : void 0
|
|
6487
|
+
);
|
|
6488
|
+
const probedSize = usable(probe?.sizes?.[0]);
|
|
6489
|
+
const size = declaredSize ?? probedSize;
|
|
6490
|
+
return {
|
|
6491
|
+
declared,
|
|
6492
|
+
src: safeUrl(declared),
|
|
6493
|
+
alt: og?.alt?.value ?? extraction.twitter.imageAlt?.value,
|
|
6494
|
+
from: ogUrl ? "og:image" : "twitter:image",
|
|
6495
|
+
count: extraction.openGraph.images.length,
|
|
6496
|
+
declaredSize,
|
|
6497
|
+
probedSize,
|
|
6498
|
+
probe,
|
|
6499
|
+
// Nothing in the codebase computes this. `og.image.ratio` scores it and
|
|
6500
|
+
// keeps the number to itself — and rounds to two decimals before banding,
|
|
6501
|
+
// which is why this rounds too: a caption that bands 1.69731 as "cropped"
|
|
6502
|
+
// beside a rule that bands 1.70 as ideal makes the page argue with itself.
|
|
6503
|
+
ratio: size ? Math.round(size.width / size.height * 100) / 100 : void 0
|
|
6504
|
+
};
|
|
6505
|
+
}
|
|
6506
|
+
function cardOf(extraction) {
|
|
6507
|
+
const { openGraph: og, twitter, meta, document: doc } = extraction;
|
|
6508
|
+
const url = extraction.http.finalUrl;
|
|
6509
|
+
return {
|
|
6510
|
+
url,
|
|
6511
|
+
host: hostOf2(url),
|
|
6512
|
+
path: pathOf(url),
|
|
6513
|
+
ogTitle: resolve(og.title, doc.title),
|
|
6514
|
+
ogDescription: resolve(og.description, meta.description),
|
|
6515
|
+
documentTitle: resolve(doc.title),
|
|
6516
|
+
metaDescription: resolve(meta.description),
|
|
6517
|
+
siteName: resolve(og.siteName),
|
|
6518
|
+
xTitle: resolve(twitter.title, og.title, doc.title),
|
|
6519
|
+
image: imageOf(extraction),
|
|
6520
|
+
icon: safeUrl(extraction.links.icons[0]?.href),
|
|
6521
|
+
themeColor: meta.themeColor?.value,
|
|
6522
|
+
twitterCard: twitter.card?.value,
|
|
6523
|
+
locale: og.locale?.value ?? doc.lang?.value,
|
|
6524
|
+
alternates: og.localeAlternates.map((f) => f.value),
|
|
6525
|
+
jsonLd: extraction.jsonLd,
|
|
6526
|
+
rendering: extraction.rendering,
|
|
6527
|
+
status: extraction.http.status
|
|
6528
|
+
};
|
|
6529
|
+
}
|
|
6530
|
+
function ratioNote(image) {
|
|
6531
|
+
if (image.ratio === void 0) return "no usable dimensions declared, and none decoded";
|
|
6532
|
+
const value = image.ratio.toFixed(2);
|
|
6533
|
+
const source = image.declaredSize ? "declared" : "decoded from the file";
|
|
6534
|
+
if (image.ratio >= RATIO_IDEAL2[0] && image.ratio <= RATIO_IDEAL2[1]) {
|
|
6535
|
+
return `${value}:1 ${source} \u2014 inside the 1.91:1 band every card is laid out for`;
|
|
6536
|
+
}
|
|
6537
|
+
if (image.ratio >= RATIO_ACCEPTABLE2[0] && image.ratio <= RATIO_ACCEPTABLE2[1]) {
|
|
6538
|
+
return `${value}:1 ${source} \u2014 renderable, but cropped away from 1.91:1`;
|
|
6539
|
+
}
|
|
6540
|
+
return `${value}:1 ${source} \u2014 outside anything a card renders whole`;
|
|
6541
|
+
}
|
|
6542
|
+
function probeNote(image) {
|
|
6543
|
+
if (!image.probe) return "not probed on this run";
|
|
6544
|
+
if (image.probe.ok) {
|
|
6545
|
+
const type = image.probe.contentType ? `, ${image.probe.contentType}` : "";
|
|
6546
|
+
return `answered ${image.probe.status}${type}`;
|
|
6547
|
+
}
|
|
6548
|
+
return image.probe.status === 0 ? "the request failed \u2014 nothing answered" : `answered ${image.probe.status}, which is not an image`;
|
|
6549
|
+
}
|
|
6550
|
+
function imageBox(card, className) {
|
|
6551
|
+
const image = card.image;
|
|
6552
|
+
if (!image) {
|
|
6553
|
+
return `<div class="${className} shot empty"><span>no <code>og:image</code></span></div>`;
|
|
6554
|
+
}
|
|
6555
|
+
if (image.probe && !image.probe.ok) {
|
|
6556
|
+
return `<div class="${className} shot broken"><span>the image does not answer</span><em>${esc(probeNote(image))}</em></div>`;
|
|
6557
|
+
}
|
|
6558
|
+
if (!image.src) {
|
|
6559
|
+
return `<div class="${className} shot empty"><span>not an absolute http(s) URL</span><em>${esc(clip(image.declared))}</em></div>`;
|
|
6560
|
+
}
|
|
6561
|
+
const alt = image.alt ? esc(image.alt) : "";
|
|
6562
|
+
return `<div class="${className} shot"><img src="${esc(image.src)}" alt="${alt}" loading="lazy" referrerpolicy="no-referrer"></div>`;
|
|
6563
|
+
}
|
|
6564
|
+
function frame(args) {
|
|
6565
|
+
const trap = args.trap ? `<p class="trap"><b>${esc(args.trap.label)}</b>${esc(args.trap.text)}</p>` : "";
|
|
6566
|
+
return `<article class="frame">
|
|
6567
|
+
<header><span class="name">${esc(args.name)}</span><span class="badge ${args.rigor.replace("-", "")}">${esc(args.rigor)}</span></header>
|
|
6568
|
+
<div class="stage">${args.body}</div>
|
|
6569
|
+
<footer><p>${args.note}</p>${trap}<span class="src">${esc(args.source)}</span></footer>
|
|
6570
|
+
</article>`;
|
|
6571
|
+
}
|
|
6572
|
+
function favicon(card) {
|
|
6573
|
+
return card.icon ? `<img class="fav" src="${esc(card.icon)}" alt="" loading="lazy">` : `<span class="fav placeholder">${esc(card.host.slice(0, 1).toUpperCase())}</span>`;
|
|
6574
|
+
}
|
|
6575
|
+
function googleSurface(card) {
|
|
6576
|
+
const title = card.documentTitle?.value ?? card.ogTitle?.value ?? card.url;
|
|
6577
|
+
const description = card.metaDescription?.value;
|
|
6578
|
+
const body = `<div class="serp">
|
|
6579
|
+
<div class="site">${favicon(card)}<span class="who"><b>${esc(card.siteName?.value ?? card.host)}</b><span>${esc(card.host)}${esc(card.path === "/" ? "" : ` \u203A ${card.path.replace(/^\//, "").split("/").join(" \u203A ")}`)}</span></span></div>
|
|
6580
|
+
<h4>${esc(title)}</h4>
|
|
6581
|
+
${description ? `<p class="clamp2">${esc(description)}</p>` : `<p class="none">no <code>meta description</code> \u2014 the snippet is written from the page body</p>`}
|
|
6582
|
+
</div>`;
|
|
6583
|
+
return frame({
|
|
6584
|
+
name: "Google \u2014 search result",
|
|
6585
|
+
rigor: "heuristic",
|
|
6586
|
+
body,
|
|
6587
|
+
note: "Not a card: no image, no ratio. <code>og:title</code> is one of the documented sources for the title link; <code>og:description</code> is not documented as a snippet source anywhere \u2014 the snippet comes from the page or from <code>meta description</code>.",
|
|
6588
|
+
source: "developers.google.com/search/docs/appearance/title-link \xB7 /snippet",
|
|
6589
|
+
trap: {
|
|
6590
|
+
label: "Not drawn here",
|
|
6591
|
+
text: "Google publishes no character or pixel limit \u2014 only truncation \u201Cto fit the device width\u201D. The two-line snippet above is the observed desktop shape, not a specification, and the 600px / 155-character figures in circulation are third-party measurements that disagree with each other."
|
|
6592
|
+
}
|
|
6593
|
+
});
|
|
6594
|
+
}
|
|
6595
|
+
function openGraphSurface(card) {
|
|
6596
|
+
const image = card.image;
|
|
6597
|
+
const body = `<div class="ogwrap">
|
|
6598
|
+
${imageBox(card, "og")}
|
|
6599
|
+
<dl class="facts">
|
|
6600
|
+
<dt>title</dt><dd>${card.ogTitle ? `${esc(card.ogTitle.value)} <span class="from">${esc(card.ogTitle.from)}</span>` : `<span class="none">absent</span>`}</dd>
|
|
6601
|
+
<dt>description</dt><dd>${card.ogDescription ? `${esc(card.ogDescription.value)} <span class="from">${esc(card.ogDescription.from)}</span>` : `<span class="none">absent</span>`}</dd>
|
|
6602
|
+
<dt>image</dt><dd>${image ? `<span class="mono">${esc(clip(image.declared))}</span> <span class="from">${esc(image.from)}${image.count > 1 ? ` \xB7 1 of ${image.count}` : ""}</span>` : `<span class="none">absent</span>`}</dd>
|
|
6603
|
+
<dt>size</dt><dd>${image ? esc(ratioNote(image)) : `<span class="none">\u2014</span>`}</dd>
|
|
6604
|
+
<dt>fetched</dt><dd>${image ? esc(probeNote(image)) : `<span class="none">\u2014</span>`}</dd>
|
|
6605
|
+
<dt>alt</dt><dd>${image?.alt ? esc(image.alt) : `<span class="none">absent</span>`}</dd>
|
|
6606
|
+
<dt>locale</dt><dd>${card.locale ? esc(card.locale) : `<span class="none">absent</span>`}${card.alternates.length > 0 ? ` <span class="from">+ ${esc(card.alternates.join(", "))}</span>` : ""}</dd>
|
|
6607
|
+
</dl>
|
|
6608
|
+
</div>`;
|
|
6609
|
+
return frame({
|
|
6610
|
+
name: "Open Graph \u2014 the source card",
|
|
6611
|
+
rigor: "vendor-spec",
|
|
6612
|
+
body,
|
|
6613
|
+
note: "The one fully specified surface: 1200\xD7630 recommended, 600\xD7315 floor, 200\xD7200 absolute minimum, 8MB, 1.91:1. Every surface below consumes this image, each in its own way.",
|
|
6614
|
+
source: "developers.facebook.com/docs/sharing/webmasters/images \xB7 ogp.me"
|
|
6615
|
+
});
|
|
6616
|
+
}
|
|
6617
|
+
function xSurface(card) {
|
|
6618
|
+
const title = card.xTitle?.value;
|
|
6619
|
+
const body = `<div class="xc">
|
|
6620
|
+
<div class="xshot">
|
|
6621
|
+
${imageBox(card, "x")}
|
|
6622
|
+
<div class="overlay">${title ? `<span class="lab">${esc(title)}</span>` : ""}<span class="dom">${esc(card.host)}</span></div>
|
|
6623
|
+
</div>
|
|
6624
|
+
</div>`;
|
|
6625
|
+
return frame({
|
|
6626
|
+
name: "X \u2014 link card",
|
|
6627
|
+
rigor: "unsourced",
|
|
6628
|
+
body,
|
|
6629
|
+
note: `Image, headline over the image, domain. Nothing else. This page declares <code>twitter:card</code> ${card.twitterCard ? `as <code>${esc(card.twitterCard)}</code>` : `nowhere, so X falls back to <code>og:*</code>`}.`,
|
|
6630
|
+
source: "X card documentation is not publicly reachable \u2014 dated behaviour, not a spec",
|
|
6631
|
+
trap: {
|
|
6632
|
+
label: "The old card is a lie",
|
|
6633
|
+
text: "X removed both headline and description on 4 October 2023. The headline came back in January 2024 as small text over the image; the description never did. Headline-below-image exists only for ads since May 2025. Any tool still drawing three stacked rows is drawing a card that has not existed for years."
|
|
6634
|
+
}
|
|
6635
|
+
});
|
|
6636
|
+
}
|
|
6637
|
+
function linkedInSurface(card) {
|
|
6638
|
+
const title = card.ogTitle?.value ?? card.url;
|
|
6639
|
+
const narrow = card.image?.declaredSize && card.image.declaredSize.width < 401;
|
|
6640
|
+
const body = `<div class="lic">
|
|
6641
|
+
${imageBox(card, "li")}
|
|
6642
|
+
<div class="meta"><b>${esc(title)}</b><span>${esc(card.host)}</span></div>
|
|
6643
|
+
</div>`;
|
|
6644
|
+
return frame({
|
|
6645
|
+
name: "LinkedIn \u2014 post unfurl",
|
|
6646
|
+
rigor: "vendor-spec",
|
|
6647
|
+
body,
|
|
6648
|
+
note: "Four tags required \u2014 <code>og:title</code>, <code>og:image</code>, <code>og:description</code>, <code>og:url</code> \u2014 and a published geometry: 1200\xD7627 minimum, 1.91:1, 5MB. Under 401px wide the card falls back to a thumbnail, which is the only documented layout switch of any surface here.",
|
|
6649
|
+
source: "linkedin.com/help/linkedin/answer/a521928",
|
|
6650
|
+
...narrow ? {
|
|
6651
|
+
trap: {
|
|
6652
|
+
label: "Thumbnail",
|
|
6653
|
+
text: `This image declares ${card.image?.declaredSize?.width}px wide, under LinkedIn's documented 401px cutoff \u2014 it renders as a small square, not as this card.`
|
|
6654
|
+
}
|
|
6655
|
+
} : {}
|
|
6656
|
+
});
|
|
6657
|
+
}
|
|
6658
|
+
function slackSurface(card) {
|
|
6659
|
+
const title = card.ogTitle?.value ?? card.url;
|
|
6660
|
+
const body = `<div class="slc">
|
|
6661
|
+
<span class="who">${favicon(card)}${esc(card.siteName?.value ?? card.host)}</span>
|
|
6662
|
+
<span class="t">${esc(title)}</span>
|
|
6663
|
+
${card.ogDescription ? `<p>${esc(card.ogDescription.value)}</p>` : ""}
|
|
6664
|
+
${imageBox(card, "sl")}
|
|
6665
|
+
</div>`;
|
|
6666
|
+
return frame({
|
|
6667
|
+
name: "Slack \u2014 unfurl",
|
|
6668
|
+
rigor: "guideline",
|
|
6669
|
+
body,
|
|
6670
|
+
note: "Slack documents what it reads \u2014 Open Graph and X Card metadata \u2014 and calls the result \u201Csome micro-approximation of the content\u201D. No geometry, no image minimum, no character limit is published. The one hard number: past five links in a message, nothing expands.",
|
|
6671
|
+
source: "docs.slack.dev/messaging/unfurling-links-in-messages"
|
|
6672
|
+
});
|
|
6673
|
+
}
|
|
6674
|
+
function discordSurface(card) {
|
|
6675
|
+
const title = card.ogTitle?.value ?? card.url;
|
|
6676
|
+
const accent = /^#[0-9a-f]{3,8}$/i.test(card.themeColor ?? "") ? card.themeColor : void 0;
|
|
6677
|
+
const body = `<div class="dcw"><div class="dcc"${accent ? ` style="border-left-color:${esc(accent)}"` : ""}>
|
|
6678
|
+
<span class="site">${esc(card.siteName?.value ?? card.host)}</span>
|
|
6679
|
+
<span class="t">${esc(title)}</span>
|
|
6680
|
+
${card.ogDescription ? `<p>${esc(card.ogDescription.value)}</p>` : ""}
|
|
6681
|
+
${imageBox(card, "dc")}
|
|
6682
|
+
</div></div>`;
|
|
6683
|
+
return frame({
|
|
6684
|
+
name: "Discord \u2014 embed",
|
|
6685
|
+
rigor: "unsourced",
|
|
6686
|
+
body,
|
|
6687
|
+
note: `Discord publishes nothing about link unfurls: no tag table, no geometry. The accent bar is drawn from ${accent ? `this page's <code>theme-color</code>` : "a default, since this page declares no <code>theme-color</code>"}, which is community-reported behaviour and not documented.`,
|
|
6688
|
+
source: "no Discord source exists for link previews",
|
|
6689
|
+
trap: {
|
|
6690
|
+
label: "Wrong numbers, right document",
|
|
6691
|
+
text: "Discord's documented 256 / 4096 / 6000 character limits are real, and they govern embeds a bot sends through the API. Using them as unfurl truncation would be a sourcing error of exactly the kind the rigor scale exists to prevent."
|
|
6692
|
+
}
|
|
6693
|
+
});
|
|
6694
|
+
}
|
|
6695
|
+
function whatsAppSurface(card) {
|
|
6696
|
+
const title = card.ogTitle?.value ?? card.url;
|
|
6697
|
+
const body = `<div class="wac"><div class="wab"><div class="q">
|
|
6698
|
+
${imageBox(card, "wa")}
|
|
6699
|
+
<div class="txt"><b class="clamp2">${esc(title)}</b>${card.ogDescription ? `<p class="clamp2">${esc(card.ogDescription.value)}</p>` : ""}<span>${esc(card.host)}</span></div>
|
|
6700
|
+
</div><div class="msg">${esc(card.url)}</div></div></div>`;
|
|
6701
|
+
return frame({
|
|
6702
|
+
name: "WhatsApp \u2014 link preview",
|
|
6703
|
+
rigor: "vendor-spec",
|
|
6704
|
+
body,
|
|
6705
|
+
note: "The one messenger with a real spec from Meta: image under 600KB, at least 300px wide, aspect ratio 4:1 or less, title in at most two lines, description in one or two \u2014 \u201C80 characters will suffice\u201D. It is also the only surface where a 1200\xD7630 card is more often too heavy than too small.",
|
|
6706
|
+
source: "developers.facebook.com/documentation/business-messaging/whatsapp/link-previews"
|
|
6707
|
+
});
|
|
6708
|
+
}
|
|
6709
|
+
function findingsPanel(issues) {
|
|
6710
|
+
const head = issues.filter((i) => HEAD_PREFIXES.some((p) => i.ruleId.startsWith(p)));
|
|
6711
|
+
const others = issues.length - head.length;
|
|
6712
|
+
const rest = others > 0 ? `<p class="foot">${others} other finding${others === 1 ? "" : "s"} on this page \u2014 they are in the report, not on a card.</p>` : "";
|
|
6713
|
+
if (head.length === 0) {
|
|
6714
|
+
return `<section class="panel"><h3>Findings on the head</h3><p class="none">Nothing the catalogue judges about this page's head. That is not the same as a card worth sharing \u2014 which is what the surfaces above are for.</p>${rest}</section>`;
|
|
6715
|
+
}
|
|
6716
|
+
const rows = head.map((issue) => {
|
|
6717
|
+
const expected = "expected" in issue ? issue.expected : void 0;
|
|
6718
|
+
return `<li class="find ${esc(issue.severity)}">
|
|
6719
|
+
<span class="rid">${esc(issue.ruleId)}</span>
|
|
6720
|
+
<span class="msg">${codeSpans(esc(issue.message))}</span>
|
|
6721
|
+
<span class="pin">${esc(issue.severity)}${issue.rigor ? ` \xB7 ${esc(issue.rigor)}` : ""}${expected ? ` \xB7 expected ${codeSpans(esc(expected))}` : ""}</span>
|
|
6722
|
+
</li>`;
|
|
6723
|
+
}).join("\n");
|
|
6724
|
+
return `<section class="panel"><h3>Findings on the head</h3><ul class="finds">${rows}</ul>${rest}</section>`;
|
|
6725
|
+
}
|
|
6726
|
+
function jsonLdPanel(blocks) {
|
|
6727
|
+
const note = `<p class="foot">Shown, never judged: no rule in the catalogue reads <code>jsonLd</code>, and that refusal is deliberate \u2014 nothing says which schema a page <em>should</em> carry.</p>`;
|
|
6728
|
+
if (blocks.length === 0) {
|
|
6729
|
+
return `<section class="panel"><h3>JSON-LD <span class="badge unjudged">extracted, unjudged</span></h3><p class="none">No <code>application/ld+json</code> block on this page \u2014 and no rule can hold that against it.</p>${note}</section>`;
|
|
6730
|
+
}
|
|
6731
|
+
const chips = blocks.map((block) => {
|
|
6732
|
+
const label = block.parseError ? `#${block.index} ${block.parseError}` : `#${block.index} ${block.types.join(", ") || "no @type"}`;
|
|
6733
|
+
return `<span class="chip${block.parseError ? " bad" : ""}">${esc(label)}</span>`;
|
|
6734
|
+
}).join("");
|
|
6735
|
+
const first = blocks.find((block) => block.data !== null);
|
|
6736
|
+
const pretty = first ? prettyJson(first.data) : void 0;
|
|
6737
|
+
const body = pretty === void 0 ? `<pre>${esc(blocks[0]?.raw.slice(0, 2e3) ?? "")}</pre>` : `<pre>${esc(pretty)}</pre>`;
|
|
6738
|
+
return `<section class="panel"><h3>JSON-LD <span class="badge unjudged">extracted, unjudged</span></h3><div class="chips">${chips}</div>${body}${note}</section>`;
|
|
6739
|
+
}
|
|
6740
|
+
function renderingNote(card) {
|
|
6741
|
+
if (card.rendering.mode === "static") {
|
|
6742
|
+
return `read from the static HTML \u2014 what a crawler that runs no JavaScript sees`;
|
|
6743
|
+
}
|
|
6744
|
+
return card.rendering.escalated ? `read from the hydrated DOM, after the static head looked empty${card.rendering.escalationReason ? ` (${card.rendering.escalationReason})` : ""} \u2014 a crawler that runs no JavaScript sees less than this` : `read from the hydrated DOM \u2014 a crawler that runs no JavaScript may see less than this`;
|
|
6745
|
+
}
|
|
6746
|
+
function graphemes(value) {
|
|
6747
|
+
if (typeof Intl?.Segmenter === "function") {
|
|
6748
|
+
return [...new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(value)].length;
|
|
6749
|
+
}
|
|
6750
|
+
return [...value].length;
|
|
6751
|
+
}
|
|
6752
|
+
function translationsPanel(current, siblings) {
|
|
6753
|
+
if (siblings.length < 2) return "";
|
|
6754
|
+
const rows = siblings.map((s) => {
|
|
6755
|
+
const here = s.index === current.index;
|
|
6756
|
+
return `<tr${here ? ' class="here"' : ""}>
|
|
6757
|
+
<td class="loc">${here ? "\u25B8 " : ""}${esc(s.locale)}</td>
|
|
6758
|
+
<td class="og">${s.ogLocale ? esc(s.ogLocale) : `<span class="none">no og:locale</span>`}</td>
|
|
6759
|
+
<td>${here ? esc(s.title) : `<a href="#page-${s.index}">${esc(s.title)}</a>`}</td>
|
|
6760
|
+
<td class="num">${graphemes(s.title)}</td>
|
|
6761
|
+
<td class="num">${s.description ? graphemes(s.description) : `<span class="none">\u2014</span>`}</td>
|
|
6762
|
+
</tr>`;
|
|
6763
|
+
}).join("\n");
|
|
6764
|
+
return `<section class="panel"><h3>This route in ${siblings.length} languages</h3>
|
|
6765
|
+
<div class="tw"><table class="locs">
|
|
6766
|
+
<thead><tr><th>path</th><th>og:locale</th><th>og:title</th><th class="num">gr.</th><th class="num">desc.</th></tr></thead>
|
|
6767
|
+
<tbody>${rows}</tbody>
|
|
6768
|
+
</table></div>
|
|
6769
|
+
<p class="foot">Lengths are graphemes, which is what a card's size ladder counts. The ladder is the site's own \u2014 <code>@goflag/og</code> ships none \u2014 so this reports the length and not the step.</p></section>`;
|
|
6770
|
+
}
|
|
6771
|
+
function tagLabel(tag) {
|
|
6772
|
+
const key = tag.property ?? tag.name ?? (tag.httpEquiv ? `http-equiv=${tag.httpEquiv}` : void 0);
|
|
6773
|
+
if (key) return `${key}${tag.content ? ` = ${clip(tag.content, 120)}` : ""}`;
|
|
6774
|
+
return `rel=${tag.rel ?? "?"}${tag.href ? ` \u2192 ${clip(tag.href, 120)}` : ""}`;
|
|
6775
|
+
}
|
|
6776
|
+
function hydrationPanel(extraction) {
|
|
6777
|
+
const delta = extraction.hydration;
|
|
6778
|
+
if (!delta) {
|
|
6779
|
+
const why = extraction.rendering.mode === "static" ? "this run never rendered the page, so there is no second reading to compare against" : "this run rendered the page without keeping a static reading to compare against";
|
|
6780
|
+
return `<section class="panel"><h3>Static vs hydrated</h3><p class="none">Not established \u2014 ${why}.</p></section>`;
|
|
6781
|
+
}
|
|
6782
|
+
const lines = [
|
|
6783
|
+
...delta.injectedMetas.map((m) => ({ sign: "+", text: tagLabel(m) })),
|
|
6784
|
+
...delta.injectedLinks.map((l) => ({ sign: "+", text: tagLabel(l) })),
|
|
6785
|
+
...delta.removedMetas.map((m) => ({ sign: "\u2212", text: tagLabel(m) })),
|
|
6786
|
+
...delta.removedLinks.map((l) => ({ sign: "\u2212", text: tagLabel(l) })),
|
|
6787
|
+
...delta.titleChanged ? [{ sign: "+", text: "<title> \u2014 rewritten after hydration" }] : [],
|
|
6788
|
+
...delta.htmlLangChanged ? [{ sign: "+", text: "<html lang> \u2014 changed after hydration" }] : [],
|
|
6789
|
+
...delta.jsonLdBlocksAdded > 0 ? [{ sign: "+", text: `${delta.jsonLdBlocksAdded} JSON-LD block(s) added by script` }] : []
|
|
6790
|
+
];
|
|
6791
|
+
if (lines.length === 0) {
|
|
6792
|
+
return `<section class="panel"><h3>Static vs hydrated</h3><p class="none">Both readings agree: every tag above is in the HTML before any script runs.</p></section>`;
|
|
6793
|
+
}
|
|
6794
|
+
const rows = lines.map(
|
|
6795
|
+
(l) => `<li class="${l.sign === "+" ? "added" : "removed"}"><span class="sign">${l.sign}</span>${esc(l.text)}</li>`
|
|
6796
|
+
).join("\n");
|
|
6797
|
+
return `<section class="panel"><h3>Static vs hydrated <span class="badge unjudged">no rule reads this</span></h3>
|
|
6798
|
+
<ul class="delta">${rows}</ul>
|
|
6799
|
+
<p class="foot">A <code>+</code> is a tag only the browser has. Unfurlers run no JavaScript, so the cards above are drawn from something a crawler may never receive \u2014 and every <code>og.*</code> rule judges the declaration it was handed, not which pass handed it over.</p></section>`;
|
|
6800
|
+
}
|
|
6801
|
+
function pageSection(extraction, issues, index, siblings) {
|
|
6802
|
+
const card = cardOf(extraction);
|
|
6803
|
+
const surfaces = [
|
|
6804
|
+
openGraphSurface(card),
|
|
6805
|
+
googleSurface(card),
|
|
6806
|
+
xSurface(card),
|
|
6807
|
+
linkedInSurface(card),
|
|
6808
|
+
slackSurface(card),
|
|
6809
|
+
discordSurface(card),
|
|
6810
|
+
whatsAppSurface(card)
|
|
6811
|
+
].join("\n");
|
|
6812
|
+
return `<section class="page" id="page-${index}">
|
|
6813
|
+
<header class="pagehead">
|
|
6814
|
+
<h2>${esc(card.path)}</h2>
|
|
6815
|
+
<p class="url">${esc(card.url)}</p>
|
|
6816
|
+
<p class="meta">${card.status} \xB7 ${esc(renderingNote(card))}${card.locale ? ` \xB7 ${esc(card.locale)}` : ""}</p>
|
|
6817
|
+
</header>
|
|
6818
|
+
<div class="surfaces">${surfaces}</div>
|
|
6819
|
+
${findingsPanel(issues)}
|
|
6820
|
+
${translationsPanel(
|
|
6821
|
+
siblings.find((sib) => sib.index === index),
|
|
6822
|
+
siblings
|
|
6823
|
+
)}
|
|
6824
|
+
${hydrationPanel(extraction)}
|
|
6825
|
+
${jsonLdPanel(card.jsonLd)}
|
|
6826
|
+
</section>`;
|
|
6827
|
+
}
|
|
6828
|
+
var STYLE = `
|
|
6829
|
+
:root{--paper:#fff;--paper2:#f7f8f7;--ink:#09090b;--ink2:#4c534f;--ink3:#6d7671;--line:#e5e6e5;--line2:#d3d6d4;--brand:#009966;--green:#007a55;--amber:#bb4d00;--red:#e7000b;--wash:#f2f6f4;
|
|
6830
|
+
--g-title:#1a0dab;--g-text:#4d5156;--x-bg:#000;--x-fg:#e7e9ea;--x-line:#2f3336;--li-strip:#f3f2ef;--sl-link:#1264a3;--sl-rail:#ddd;--dc-bg:#313338;--dc-embed:#2b2d31;--dc-fg:#dbdee1;--dc-dim:#b5bac1;--dc-link:#00a8fc;--wa-bg:#efeae2;--wa-bubble:#d9fdd3;--wa-quote:#d1f4cb;--wa-fg:#111b21;--wa-dim:#667781;
|
|
6831
|
+
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;--ui:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
6832
|
+
@media (prefers-color-scheme:dark){:root{--paper:#0a0a0c;--paper2:#121316;--ink:#fafafa;--ink2:#b6bcb9;--ink3:#878e8a;--line:#23252a;--line2:#32353b;--brand:#00d492;--green:#00d492;--amber:#ffb900;--red:#ff6467;--wash:#101614}}
|
|
6833
|
+
*{box-sizing:border-box}
|
|
6834
|
+
body{margin:0;padding:0 20px 100px;background:var(--paper);color:var(--ink);font:15px/1.6 var(--ui);-webkit-font-smoothing:antialiased}
|
|
6835
|
+
.wrap{max-width:1120px;margin-inline:auto}
|
|
6836
|
+
h1,h2,h3,.badge,.chip,.rid,.src,.from,.mono,code,pre,dt,.pin,.nav{font-family:var(--mono)}
|
|
6837
|
+
h1{font-size:22px;letter-spacing:-.02em;margin:0 0 6px}
|
|
6838
|
+
.lede{color:var(--ink2);margin:0;max-width:70ch}
|
|
6839
|
+
header.doc{padding:44px 0 24px;border-bottom:1px solid var(--line)}
|
|
6840
|
+
.nav{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0 0;padding:0;list-style:none;font-size:12px}
|
|
6841
|
+
.nav a{display:inline-block;padding:4px 9px;border:1px solid var(--line2);border-radius:3px;color:var(--ink2);text-decoration:none}
|
|
6842
|
+
.nav a:hover,.nav a:focus-visible{border-color:var(--brand);color:var(--brand)}
|
|
6843
|
+
.page{padding-top:52px}
|
|
6844
|
+
.pagehead h2{font-size:16px;margin:0 0 4px}
|
|
6845
|
+
.pagehead .url{margin:0;font-size:13px;color:var(--ink2);word-break:break-all}
|
|
6846
|
+
.pagehead .meta{margin:6px 0 0;font-size:12px;color:var(--ink3);font-family:var(--mono)}
|
|
6847
|
+
.surfaces{display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:22px;margin-top:22px}
|
|
6848
|
+
.frame{border:1px solid var(--line);border-radius:8px;background:var(--paper2);display:flex;flex-direction:column;overflow:hidden}
|
|
6849
|
+
.frame>header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 13px;border-bottom:1px solid var(--line);background:var(--paper)}
|
|
6850
|
+
.frame>header .name{font-size:12px;font-weight:600}
|
|
6851
|
+
.frame .stage{padding:16px;display:flex;justify-content:center}
|
|
6852
|
+
.frame footer{margin-top:auto;border-top:1px solid var(--line);padding:11px 13px;font-size:12.5px;line-height:1.5;color:var(--ink2)}
|
|
6853
|
+
.frame footer p{margin:0 0 6px}
|
|
6854
|
+
.src{display:block;font-size:10.5px;color:var(--ink3);word-break:break-all}
|
|
6855
|
+
.badge{font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:3px 7px;border-radius:3px;border:1px solid var(--line2);color:var(--ink3);white-space:nowrap}
|
|
6856
|
+
.badge.vendorspec{color:var(--green);border-color:currentColor}
|
|
6857
|
+
.badge.guideline,.badge.heuristic{color:var(--amber);border-color:currentColor}
|
|
6858
|
+
.badge.unsourced{color:var(--red);border-color:currentColor}
|
|
6859
|
+
.badge.unjudged{color:var(--ink3);text-transform:none;letter-spacing:0}
|
|
6860
|
+
.trap{margin:8px 0 0;padding-left:10px;border-left:2px solid var(--red);font-size:12px}
|
|
6861
|
+
.trap b{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--red)}
|
|
6862
|
+
.shot{width:100%;overflow:hidden;background:#0d0e10}
|
|
6863
|
+
.shot img{display:block;width:100%;height:auto}
|
|
6864
|
+
.shot.empty,.shot.broken{aspect-ratio:1.91/1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;background:var(--wash);color:var(--ink3);font-size:12.5px;text-align:center;padding:12px;border:1px dashed var(--line2)}
|
|
6865
|
+
.shot.broken{border-color:var(--red);color:var(--red)}
|
|
6866
|
+
.shot em{font-family:var(--mono);font-size:11px;font-style:normal;opacity:.85;word-break:break-all}
|
|
6867
|
+
.ogwrap{width:100%;display:flex;flex-direction:column;gap:14px}
|
|
6868
|
+
.facts{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:12.5px}
|
|
6869
|
+
.facts dt{color:var(--ink3);font-size:11px}
|
|
6870
|
+
.facts dd{margin:0;word-break:break-word}
|
|
6871
|
+
.from{font-family:var(--mono);font-size:10.5px;color:var(--brand)}
|
|
6872
|
+
.none{color:var(--ink3);font-style:italic}
|
|
6873
|
+
.mono{font-family:var(--mono);font-size:11.5px;word-break:break-all}
|
|
6874
|
+
.clamp2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
6875
|
+
.serp{width:100%;max-width:600px;font-family:Arial,var(--ui)}
|
|
6876
|
+
.serp .site{display:flex;align-items:center;gap:10px;margin-bottom:4px}
|
|
6877
|
+
.fav{width:26px;height:26px;border-radius:50%;flex:none;object-fit:contain;background:var(--wash)}
|
|
6878
|
+
.fav.placeholder{display:grid;place-items:center;font-family:var(--mono);font-size:12px;color:var(--ink2)}
|
|
6879
|
+
.serp .who b{display:block;font-size:14px;font-weight:400}
|
|
6880
|
+
.serp .who span{font-size:12px;color:var(--ink3)}
|
|
6881
|
+
.serp h4{font-size:19px;line-height:1.3;font-weight:400;color:var(--g-title);margin:4px 0 3px}
|
|
6882
|
+
.serp p{font-size:13.5px;line-height:1.56;margin:0;color:var(--g-text)}
|
|
6883
|
+
@media (prefers-color-scheme:dark){.serp h4{color:#8ab4f8}.serp p{color:var(--ink2)}}
|
|
6884
|
+
.xc{width:100%;max-width:440px}
|
|
6885
|
+
.xshot{position:relative;border:1px solid var(--x-line);border-radius:14px;overflow:hidden;background:var(--x-bg)}
|
|
6886
|
+
.overlay{position:absolute;left:10px;right:10px;bottom:10px;display:flex;flex-direction:column;align-items:flex-start;gap:4px}
|
|
6887
|
+
.overlay .lab{background:#000000bf;color:var(--x-fg);font-size:12px;line-height:1.25;padding:3px 7px;border-radius:4px;max-width:100%}
|
|
6888
|
+
.overlay .dom{font-size:11px;color:#fffc;text-shadow:0 1px 2px #000}
|
|
6889
|
+
.lic{width:100%;max-width:440px;border:1px solid var(--line2);border-radius:2px;overflow:hidden}
|
|
6890
|
+
.lic .meta{padding:10px 12px;background:var(--li-strip);color:#000}
|
|
6891
|
+
.lic .meta b{display:block;font-size:14px;line-height:1.35}
|
|
6892
|
+
.lic .meta span{font-size:12px;color:#00000099}
|
|
6893
|
+
.slc{width:100%;max-width:440px;padding:2px 0 2px 12px;border-left:4px solid var(--sl-rail)}
|
|
6894
|
+
.slc .who{display:flex;align-items:center;gap:7px;font-size:12.5px;font-weight:700;margin-bottom:3px}
|
|
6895
|
+
.slc .who .fav{width:16px;height:16px;border-radius:3px}
|
|
6896
|
+
.slc .t{display:block;font-size:14px;font-weight:700;color:var(--sl-link);margin-bottom:3px}
|
|
6897
|
+
.slc p{font-size:13px;line-height:1.45;margin:0 0 8px}
|
|
6898
|
+
.slc .shot{max-width:360px;border-radius:8px}
|
|
6899
|
+
.dcw{width:100%;max-width:440px;background:var(--dc-bg);padding:11px;border-radius:8px}
|
|
6900
|
+
.dcc{background:var(--dc-embed);border-left:4px solid var(--brand);border-radius:4px;padding:11px 13px 13px}
|
|
6901
|
+
.dcc .site{font-size:11.5px;color:var(--dc-fg)}
|
|
6902
|
+
.dcc .t{display:block;font-size:15px;font-weight:600;color:var(--dc-link);line-height:1.3;margin:5px 0}
|
|
6903
|
+
.dcc p{font-size:13px;line-height:1.42;color:var(--dc-dim);margin:0 0 9px}
|
|
6904
|
+
.dcc .shot{border-radius:4px}
|
|
6905
|
+
.wac{width:100%;max-width:330px;background:var(--wa-bg);padding:12px;border-radius:8px}
|
|
6906
|
+
.wab{background:var(--wa-bubble);border-radius:8px;padding:4px}
|
|
6907
|
+
.wab .q{background:var(--wa-quote);border-radius:6px;overflow:hidden}
|
|
6908
|
+
.wab .txt{padding:8px 10px;color:var(--wa-fg)}
|
|
6909
|
+
.wab .txt b{display:block;font-size:12.5px;line-height:1.3}
|
|
6910
|
+
.wab .txt p{font-size:12px;color:var(--wa-dim);margin:2px 0 0;line-height:1.35}
|
|
6911
|
+
.wab .txt span{font-size:11px;color:var(--wa-dim)}
|
|
6912
|
+
.wab .msg{padding:5px 9px 3px;font-size:13px;color:var(--wa-fg);word-break:break-all}
|
|
6913
|
+
.panel{margin-top:22px;border:1px solid var(--line);border-radius:8px;padding:16px 18px;background:var(--paper2)}
|
|
6914
|
+
.panel h3{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink3);margin:0 0 12px;display:flex;align-items:center;gap:10px}
|
|
6915
|
+
.panel .foot{margin:12px 0 0;font-size:12.5px;color:var(--ink3)}
|
|
6916
|
+
.finds{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
|
|
6917
|
+
.find{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;padding:9px 0 9px 12px;border-left:3px solid var(--ink3);border-bottom:1px solid var(--line)}
|
|
6918
|
+
.find:last-child{border-bottom:0}
|
|
6919
|
+
.find.error{border-left-color:var(--red)}
|
|
6920
|
+
.find.warning{border-left-color:var(--amber)}
|
|
6921
|
+
.find .rid{font-size:12.5px;font-weight:600}
|
|
6922
|
+
.find .msg{font-size:13.5px;color:var(--ink2)}
|
|
6923
|
+
.find .pin{grid-column:1/-1;font-size:11px;color:var(--ink3)}
|
|
6924
|
+
.tw{overflow-x:auto}
|
|
6925
|
+
table.locs{border-collapse:collapse;width:100%;font-size:13px}
|
|
6926
|
+
table.locs th{text-align:left;font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink3);font-weight:600;padding:0 12px 7px 0;border-bottom:1px solid var(--line)}
|
|
6927
|
+
table.locs td{padding:7px 12px 7px 0;border-bottom:1px solid var(--line);vertical-align:top}
|
|
6928
|
+
table.locs tr:last-child td{border-bottom:0}
|
|
6929
|
+
table.locs .loc,table.locs .og{font-family:var(--mono);font-size:12px;white-space:nowrap}
|
|
6930
|
+
table.locs .num{font-family:var(--mono);text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
6931
|
+
table.locs tr.here td{color:var(--ink);font-weight:600}
|
|
6932
|
+
table.locs tr.here .loc{color:var(--brand)}
|
|
6933
|
+
.delta{list-style:none;margin:0;padding:0;font-family:var(--mono);font-size:12.5px;line-height:1.7}
|
|
6934
|
+
.delta li{display:flex;gap:9px;word-break:break-word}
|
|
6935
|
+
.delta .sign{flex:none;width:1ch;font-weight:700}
|
|
6936
|
+
.delta .added .sign,.delta li.added{color:var(--green)}
|
|
6937
|
+
.delta .removed .sign,.delta li.removed{color:var(--red)}
|
|
6938
|
+
.chips{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:12px}
|
|
6939
|
+
.chip{font-size:11px;padding:3px 8px;border:1px solid var(--line2);border-radius:3px;color:var(--ink2)}
|
|
6940
|
+
.chip.bad{color:var(--red);border-color:currentColor}
|
|
6941
|
+
pre{margin:0;padding:13px 15px;background:#121416;color:#d8dbde;border-radius:6px;font-size:11.5px;line-height:1.55;overflow-x:auto}
|
|
6942
|
+
code{font-size:.9em;background:var(--wash);border:1px solid var(--line);border-radius:3px;padding:.05em .3em}
|
|
6943
|
+
footer.doc{margin-top:70px;padding-top:20px;border-top:1px solid var(--line);font-family:var(--mono);font-size:11.5px;color:var(--ink3)}
|
|
6944
|
+
a{color:var(--brand)}
|
|
6945
|
+
:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
|
|
6946
|
+
`;
|
|
6947
|
+
function renderPreview(report, options = {}) {
|
|
6948
|
+
const title = options.title ?? `goflag preview \u2014 ${hostOf2(report.url)}`;
|
|
6949
|
+
const extractions = report.extractions ?? [];
|
|
6950
|
+
const issuesByPage = /* @__PURE__ */ new Map();
|
|
6951
|
+
for (const issue of [...report.seoIssues, ...report.siteIssues]) {
|
|
6952
|
+
const bucket = issuesByPage.get(issue.pageUrl);
|
|
6953
|
+
if (bucket) bucket.push(issue);
|
|
6954
|
+
else issuesByPage.set(issue.pageUrl, [issue]);
|
|
6955
|
+
}
|
|
6956
|
+
const empty = report.extractions === void 0 ? `This report carries no extractions, so there is nothing to draw. Pass <code>extractions: true</code> to <code>runAudit</code>, or use <code>goflag preview <url></code>, which does.` : `The crawl reached no HTML page it could read, so there is nothing to draw. Every page was unreachable, not HTML, or a declared duplicate of another.`;
|
|
6957
|
+
const rows = extractions.map((extraction, index) => {
|
|
6958
|
+
const path = pathOf(extraction.http.finalUrl);
|
|
6959
|
+
return {
|
|
6960
|
+
index,
|
|
6961
|
+
locale: splitRoute(path).locale,
|
|
6962
|
+
path,
|
|
6963
|
+
ogLocale: extraction.openGraph.locale?.value,
|
|
6964
|
+
title: cardOf(extraction).ogTitle?.value ?? path,
|
|
6965
|
+
description: cardOf(extraction).ogDescription?.value ?? ""
|
|
6966
|
+
};
|
|
6967
|
+
});
|
|
6968
|
+
const byRoute = /* @__PURE__ */ new Map();
|
|
6969
|
+
extractions.forEach((extraction, index) => {
|
|
6970
|
+
const route = splitRoute(pathOf(extraction.http.finalUrl)).route;
|
|
6971
|
+
byRoute.set(route, [...byRoute.get(route) ?? [], rows[index]]);
|
|
6972
|
+
});
|
|
6973
|
+
const body = extractions.length === 0 ? `<section class="page"><p class="none">${empty}</p></section>` : extractions.map(
|
|
6974
|
+
(extraction, index) => pageSection(
|
|
6975
|
+
extraction,
|
|
6976
|
+
issuesByPage.get(extraction.http.finalUrl) ?? [],
|
|
6977
|
+
index,
|
|
6978
|
+
byRoute.get(splitRoute(pathOf(extraction.http.finalUrl)).route) ?? []
|
|
6979
|
+
)
|
|
6980
|
+
).join("\n");
|
|
6981
|
+
const nav = extractions.length > 1 ? `<ul class="nav">${extractions.map(
|
|
6982
|
+
(extraction, index) => `<li><a href="#page-${index}">${esc(pathOf(extraction.http.finalUrl))}</a></li>`
|
|
6983
|
+
).join("")}</ul>` : "";
|
|
6984
|
+
return `<!doctype html>
|
|
6985
|
+
<html lang="en">
|
|
6986
|
+
<head>
|
|
6987
|
+
<meta charset="utf-8">
|
|
6988
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6989
|
+
<meta name="robots" content="noindex">
|
|
6990
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http: https: data:; style-src 'unsafe-inline'">
|
|
6991
|
+
<title>${esc(title)}</title>
|
|
6992
|
+
<style>${STYLE}</style>
|
|
6993
|
+
</head>
|
|
6994
|
+
<body>
|
|
6995
|
+
<div class="wrap">
|
|
6996
|
+
<header class="doc">
|
|
6997
|
+
<h1>${esc(title)}</h1>
|
|
6998
|
+
<p class="lede">What each surface makes of ${esc(report.url)}, drawn from what the pages declared. Each surface carries the rigor of the geometry it is drawn from \u2014 three of the seven publish one, two publish nothing at all, and Google is not a card.</p>
|
|
6999
|
+
${nav}
|
|
7000
|
+
</header>
|
|
7001
|
+
${body}
|
|
7002
|
+
<footer class="doc">${esc(`${extractions.length} page${extractions.length === 1 ? "" : "s"} \xB7 audited ${report.finishedAt} \xB7 profile ${report.profile} \xB7 goflag`)}</footer>
|
|
7003
|
+
</div>
|
|
7004
|
+
</body>
|
|
7005
|
+
</html>
|
|
7006
|
+
`;
|
|
7007
|
+
}
|
|
7008
|
+
|
|
6006
7009
|
// src/report/summarize.ts
|
|
6007
7010
|
var SAMPLE_LIMIT = 5;
|
|
6008
7011
|
function summarize(report) {
|
|
@@ -6277,6 +7280,7 @@ export {
|
|
|
6277
7280
|
PROFILES,
|
|
6278
7281
|
PROFILE_NAMES,
|
|
6279
7282
|
PROSE_RULES,
|
|
7283
|
+
SITE_PROSE_RULES,
|
|
6280
7284
|
routeKey,
|
|
6281
7285
|
targetKey,
|
|
6282
7286
|
fingerprint,
|
|
@@ -6285,8 +7289,9 @@ export {
|
|
|
6285
7289
|
runAudit,
|
|
6286
7290
|
renderTerminal,
|
|
6287
7291
|
renderSummaryTerminal,
|
|
7292
|
+
renderPreview,
|
|
6288
7293
|
SAMPLE_LIMIT,
|
|
6289
7294
|
summarize,
|
|
6290
7295
|
Logger
|
|
6291
7296
|
};
|
|
6292
|
-
//# sourceMappingURL=chunk-
|
|
7297
|
+
//# sourceMappingURL=chunk-QKXUKHXB.js.map
|