@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.
@@ -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 pathOf = (value) => {
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 pathOf(href) === pathOf(src);
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
- var hreflangSitemapMismatch = {
2801
- id: "hreflang.sitemap-mismatch",
2802
- severity: "warning",
2803
- summary: "`<head>` alternates and sitemap locale coverage must agree",
2804
- appliesTo: (site) => isMultilingual(site) && (site.discovery?.urls.length ?? 0) > 0,
2805
- check: ({ site, issue }) => {
2806
- const bySitemap = sitemapLocalesByRoute(site);
2807
- const findings = [];
2808
- for (const page of site.pages) {
2809
- const head = declaredLocales(page);
2810
- if (head.size === 0) continue;
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
- return findings;
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
- hreflangSitemapMismatch,
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 { origin, baseUrl, source: "well-known", urls: [], diagnostics, truncated: false };
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 { status: res.status, body: gunzipSync(buf).toString("utf8") };
4138
+ return withLength(res.status, gunzipSync(buf).toString("utf8"), true);
3900
4139
  } catch {
3901
- return { status: res.status, body: buf.toString("utf8") };
4140
+ return withLength(res.status, buf.toString("utf8"), true);
3902
4141
  }
3903
4142
  }
3904
- return { status: res.status, body: await res.text() };
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((resolve) => setTimeout(resolve, ms));
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, hostOf2) {
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((resolve) => {
5403
+ return new Promise((resolve2) => {
5034
5404
  if (items.length === 0) {
5035
- resolve();
5405
+ resolve2();
5036
5406
  return;
5037
5407
  }
5038
- const pending = items.map((item) => ({ item, host: hostOf2(item) }));
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
- resolve();
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 favicon = await probeFavicon(origin, {
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
+ "&": "&amp;",
6399
+ "<": "&lt;",
6400
+ ">": "&gt;",
6401
+ '"': "&quot;",
6402
+ "'": "&#39;"
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 &lt;url&gt;</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-DKGBLJ4H.js.map
7297
+ //# sourceMappingURL=chunk-QKXUKHXB.js.map