@goflag/cli 0.2.11 → 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 +42 -4
- package/dist/{chunk-CWEJ6EXT.js → chunk-QKXUKHXB.js} +790 -17
- package/dist/chunk-QKXUKHXB.js.map +1 -0
- package/dist/cli.js +94 -14
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +121 -1
- package/dist/index.js +3 -1
- package/flags.json +1 -1
- package/package.json +2 -2
- package/rules.json +58 -1
- package/dist/chunk-CWEJ6EXT.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,
|
|
@@ -4392,7 +4498,7 @@ function schemeOf(url) {
|
|
|
4392
4498
|
}
|
|
4393
4499
|
}
|
|
4394
4500
|
function defaultSleep(ms) {
|
|
4395
|
-
return new Promise((
|
|
4501
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
4396
4502
|
}
|
|
4397
4503
|
|
|
4398
4504
|
// src/lib/core/sitemap/entries.ts
|
|
@@ -4721,13 +4827,58 @@ async function readHead(res) {
|
|
|
4721
4827
|
} finally {
|
|
4722
4828
|
await reader.cancel().catch(() => void 0);
|
|
4723
4829
|
}
|
|
4724
|
-
return chunks.length > 0 ? Buffer.concat(chunks) : void 0;
|
|
4830
|
+
return chunks.length > 0 ? Buffer.concat(chunks).subarray(0, HEAD_BYTES) : void 0;
|
|
4725
4831
|
}
|
|
4726
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
|
+
}
|
|
4727
4873
|
function decodeSizes(head) {
|
|
4728
4874
|
if (head.length >= 24 && head.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
4729
4875
|
return [{ width: head.readUInt32BE(16), height: head.readUInt32BE(20) }];
|
|
4730
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
|
+
}
|
|
4731
4882
|
if (head.length >= 6 && head.readUInt16LE(0) === 0 && head.readUInt16LE(2) === 1) {
|
|
4732
4883
|
const count2 = head.readUInt16LE(4);
|
|
4733
4884
|
if (count2 === 0 || head.length < 6 + 16 * count2) return void 0;
|
|
@@ -5246,22 +5397,22 @@ function hostOf(url) {
|
|
|
5246
5397
|
return url;
|
|
5247
5398
|
}
|
|
5248
5399
|
}
|
|
5249
|
-
function mapWithHostCaps(items, opts, worker,
|
|
5400
|
+
function mapWithHostCaps(items, opts, worker, hostOf3) {
|
|
5250
5401
|
const concurrency = Math.max(1, opts.concurrency);
|
|
5251
5402
|
const perHost = opts.perHost;
|
|
5252
|
-
return new Promise((
|
|
5403
|
+
return new Promise((resolve2) => {
|
|
5253
5404
|
if (items.length === 0) {
|
|
5254
|
-
|
|
5405
|
+
resolve2();
|
|
5255
5406
|
return;
|
|
5256
5407
|
}
|
|
5257
|
-
const pending = items.map((item) => ({ item, host:
|
|
5408
|
+
const pending = items.map((item) => ({ item, host: hostOf3(item) }));
|
|
5258
5409
|
const hostActive = /* @__PURE__ */ new Map();
|
|
5259
5410
|
let inflight = 0;
|
|
5260
5411
|
let settled = false;
|
|
5261
5412
|
const finish = () => {
|
|
5262
5413
|
if (!settled) {
|
|
5263
5414
|
settled = true;
|
|
5264
|
-
|
|
5415
|
+
resolve2();
|
|
5265
5416
|
}
|
|
5266
5417
|
};
|
|
5267
5418
|
const pump = () => {
|
|
@@ -5518,7 +5669,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5518
5669
|
signal: options.signal,
|
|
5519
5670
|
timeoutMs: options.timeoutMs
|
|
5520
5671
|
}).catch(() => void 0);
|
|
5521
|
-
const
|
|
5672
|
+
const favicon2 = await probeFavicon(origin, {
|
|
5522
5673
|
signal: options.signal,
|
|
5523
5674
|
timeoutMs: options.timeoutMs
|
|
5524
5675
|
}).catch(() => void 0);
|
|
@@ -5578,11 +5729,15 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5578
5729
|
const seoIssues = [];
|
|
5579
5730
|
const conformanceRows = [];
|
|
5580
5731
|
const advisories = [];
|
|
5732
|
+
const extractions = [];
|
|
5581
5733
|
const ruleById = new Map(rules.map((rule) => [rule.id, rule]));
|
|
5582
5734
|
for (const page of htmlPages) {
|
|
5583
5735
|
const pageUrl = page.fetch.finalUrl;
|
|
5584
5736
|
const extraction = extractionFromPage(page);
|
|
5585
5737
|
const evaluation = evaluateRules(extraction, rules);
|
|
5738
|
+
if (options.extractions) {
|
|
5739
|
+
extractions.push(extraction);
|
|
5740
|
+
}
|
|
5586
5741
|
if (options.conformance) {
|
|
5587
5742
|
conformanceRows.push({ pageUrl, findings: evaluation.findings });
|
|
5588
5743
|
}
|
|
@@ -5695,7 +5850,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5695
5850
|
localeAxis,
|
|
5696
5851
|
discovery,
|
|
5697
5852
|
robots,
|
|
5698
|
-
favicon,
|
|
5853
|
+
favicon: favicon2,
|
|
5699
5854
|
sitemapEntries,
|
|
5700
5855
|
// Same index the matrix was built from, so a rule that groups by route
|
|
5701
5856
|
// and the grid that reports holes cannot disagree about which URLs are
|
|
@@ -5803,6 +5958,7 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5803
5958
|
// made.
|
|
5804
5959
|
...options.conformance ? { conformance: buildConformance(rules, conformanceRows) } : {},
|
|
5805
5960
|
...options.advisories ? { advisories } : {},
|
|
5961
|
+
...options.extractions ? { extractions } : {},
|
|
5806
5962
|
diagnostics: {
|
|
5807
5963
|
pagesCrawled: pages.length,
|
|
5808
5964
|
pagesScanned: linkReport.pagesScanned,
|
|
@@ -6234,6 +6390,622 @@ function rollupRows(issues, c) {
|
|
|
6234
6390
|
return lines;
|
|
6235
6391
|
}
|
|
6236
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
|
+
|
|
6237
7009
|
// src/report/summarize.ts
|
|
6238
7010
|
var SAMPLE_LIMIT = 5;
|
|
6239
7011
|
function summarize(report) {
|
|
@@ -6517,8 +7289,9 @@ export {
|
|
|
6517
7289
|
runAudit,
|
|
6518
7290
|
renderTerminal,
|
|
6519
7291
|
renderSummaryTerminal,
|
|
7292
|
+
renderPreview,
|
|
6520
7293
|
SAMPLE_LIMIT,
|
|
6521
7294
|
summarize,
|
|
6522
7295
|
Logger
|
|
6523
7296
|
};
|
|
6524
|
-
//# sourceMappingURL=chunk-
|
|
7297
|
+
//# sourceMappingURL=chunk-QKXUKHXB.js.map
|