@goflag/cli 0.2.12 → 0.2.14
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 +39 -9
- package/dist/{chunk-QKXUKHXB.js → chunk-YQPL67HX.js} +610 -69
- package/dist/chunk-YQPL67HX.js.map +1 -0
- package/dist/cli.js +63 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -2
- package/rules.json +116 -2
- package/dist/chunk-QKXUKHXB.js.map +0 -1
|
@@ -233,8 +233,8 @@ function parseGenericMeta(raw, base) {
|
|
|
233
233
|
for (const l of raw.links) {
|
|
234
234
|
const rel = l.rel?.toLowerCase();
|
|
235
235
|
if (rel === "canonical" && l.href) {
|
|
236
|
-
const
|
|
237
|
-
meta.canonical = sourced(
|
|
236
|
+
const resolved2 = safeResolve(l.href, base);
|
|
237
|
+
meta.canonical = sourced(resolved2, { kind: "link", rel: "canonical" }, l.href);
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
return meta;
|
|
@@ -1435,14 +1435,14 @@ function normalise(value) {
|
|
|
1435
1435
|
return value.trim().toLowerCase().split(/\s+/).sort().join(" ");
|
|
1436
1436
|
}
|
|
1437
1437
|
function sameAsset(href, src) {
|
|
1438
|
-
const
|
|
1438
|
+
const pathOf3 = (value) => {
|
|
1439
1439
|
try {
|
|
1440
1440
|
return new URL(value, "https://goflag.invalid").pathname;
|
|
1441
1441
|
} catch {
|
|
1442
1442
|
return value.split("?")[0] ?? value;
|
|
1443
1443
|
}
|
|
1444
1444
|
};
|
|
1445
|
-
return
|
|
1445
|
+
return pathOf3(href) === pathOf3(src);
|
|
1446
1446
|
}
|
|
1447
1447
|
function hasOpenGraph(ex) {
|
|
1448
1448
|
return Boolean(
|
|
@@ -2765,6 +2765,41 @@ function sortLocales(locales) {
|
|
|
2765
2765
|
return [...xDefault, ...rest];
|
|
2766
2766
|
}
|
|
2767
2767
|
|
|
2768
|
+
// src/lib/core/probes/not-found.ts
|
|
2769
|
+
import { randomBytes } from "crypto";
|
|
2770
|
+
async function probeNotFound(origin, options = {}) {
|
|
2771
|
+
const nonce = options.nonce ?? randomBytes(16).toString("hex");
|
|
2772
|
+
const base = `/goflag-probe-${nonce}`;
|
|
2773
|
+
return Promise.all([
|
|
2774
|
+
ask(new URL(base, origin).toString(), "bare", options),
|
|
2775
|
+
ask(new URL(`${base}.txt`, origin).toString(), "dotted", options)
|
|
2776
|
+
]);
|
|
2777
|
+
}
|
|
2778
|
+
async function ask(url, shape, options) {
|
|
2779
|
+
const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 5e3);
|
|
2780
|
+
try {
|
|
2781
|
+
let res = await fetch(url, { method: "HEAD", signal, redirect: "follow" });
|
|
2782
|
+
if (!isNotFound(res.status)) {
|
|
2783
|
+
res = await fetch(url, { signal, redirect: "follow" });
|
|
2784
|
+
await res.body?.cancel().catch(() => void 0);
|
|
2785
|
+
}
|
|
2786
|
+
return {
|
|
2787
|
+
url,
|
|
2788
|
+
shape,
|
|
2789
|
+
status: res.status,
|
|
2790
|
+
finalUrl: res.url || url,
|
|
2791
|
+
contentType: res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() || void 0
|
|
2792
|
+
};
|
|
2793
|
+
} catch {
|
|
2794
|
+
return { url, shape, status: 0, finalUrl: url };
|
|
2795
|
+
} finally {
|
|
2796
|
+
cleanup();
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
function isNotFound(status) {
|
|
2800
|
+
return status === 404 || status === 410;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2768
2803
|
// src/lib/core/robots/match.ts
|
|
2769
2804
|
function robotsAllows(groups, path, userAgent = "*") {
|
|
2770
2805
|
const group = selectGroup(groups, userAgent);
|
|
@@ -2829,6 +2864,180 @@ function escapeRegExp(value) {
|
|
|
2829
2864
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2830
2865
|
}
|
|
2831
2866
|
|
|
2867
|
+
// src/lib/rules/site-shared.ts
|
|
2868
|
+
var SAMPLE = 5;
|
|
2869
|
+
function sample(locs) {
|
|
2870
|
+
const shown = locs.slice(0, SAMPLE);
|
|
2871
|
+
const rest = locs.length - shown.length;
|
|
2872
|
+
return `${shown.map((l) => `\`${l}\``).join(", ")}${rest > 0 ? `, and ${rest} more` : ""}`;
|
|
2873
|
+
}
|
|
2874
|
+
function isDead(status) {
|
|
2875
|
+
return status === 0 || status >= 400;
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// src/lib/rules/llms-txt.ts
|
|
2879
|
+
var TEXT_TYPES = /* @__PURE__ */ new Set(["text/plain", "text/markdown", "text/x-markdown"]);
|
|
2880
|
+
function readable(site) {
|
|
2881
|
+
return site.llmsTxt?.found === true && !site.llmsTxt.html;
|
|
2882
|
+
}
|
|
2883
|
+
function explainedByCatchAll(site, status) {
|
|
2884
|
+
const dotted = site.notFound?.find((probe) => probe.shape === "dotted");
|
|
2885
|
+
if (!dotted) return false;
|
|
2886
|
+
const klass = (code) => Math.floor(code / 100);
|
|
2887
|
+
return (klass(status) === 2 || klass(status) === 5) && klass(dotted.status) === klass(status);
|
|
2888
|
+
}
|
|
2889
|
+
function sameOriginLinks(site) {
|
|
2890
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2891
|
+
const out = [];
|
|
2892
|
+
for (const link of site.llmsTxt?.links ?? []) {
|
|
2893
|
+
if (seen.has(link.url) || !sameOrigin2(link.url, site.origin)) continue;
|
|
2894
|
+
seen.add(link.url);
|
|
2895
|
+
out.push({ url: link.url, line: link.line });
|
|
2896
|
+
}
|
|
2897
|
+
return out;
|
|
2898
|
+
}
|
|
2899
|
+
function sameOrigin2(url, origin) {
|
|
2900
|
+
try {
|
|
2901
|
+
return new URL(url).origin === new URL(origin).origin;
|
|
2902
|
+
} catch {
|
|
2903
|
+
return false;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
function fileUrl(site) {
|
|
2907
|
+
return site.llmsTxt?.url ?? `${site.origin}/llms.txt`;
|
|
2908
|
+
}
|
|
2909
|
+
var llmstxtUnreachable = {
|
|
2910
|
+
id: "llmstxt.unreachable",
|
|
2911
|
+
severity: "warning",
|
|
2912
|
+
summary: "`/llms.txt` must answer with the file or with 404, not with a server error",
|
|
2913
|
+
rigor: "guideline",
|
|
2914
|
+
sources: ["lighthouse-llms-txt", "llmstxt"],
|
|
2915
|
+
// A 5xx only. No answer at all is not evidence about the route, and every
|
|
2916
|
+
// 4xx — a 404 above all — is the site saying it has no file.
|
|
2917
|
+
appliesTo: (site) => site.llmsTxt !== void 0 && site.llmsTxt.status >= 500 && !explainedByCatchAll(site, site.llmsTxt.status),
|
|
2918
|
+
check: ({ site, issue }) => issue({
|
|
2919
|
+
pageUrl: fileUrl(site),
|
|
2920
|
+
message: `\`/llms.txt\` answered ${site.llmsTxt.status}. Having no llms.txt is fine \u2014 a 404 says so \u2014 but a server error says something is here and failed, and the agent that asked for it gets nothing it can use. Lighthouse fails its llms.txt audit on exactly this.`,
|
|
2921
|
+
origin: { kind: "computed" }
|
|
2922
|
+
})
|
|
2923
|
+
};
|
|
2924
|
+
var llmstxtContentType = {
|
|
2925
|
+
id: "llmstxt.content-type",
|
|
2926
|
+
severity: "warning",
|
|
2927
|
+
summary: "`/llms.txt` must be served as text \u2014 `text/plain` or `text/markdown` \u2014 not as a page",
|
|
2928
|
+
rigor: "guideline",
|
|
2929
|
+
sources: ["llmstxt", "ietf-rfc9110"],
|
|
2930
|
+
appliesTo: (site) => {
|
|
2931
|
+
const probe = site.llmsTxt;
|
|
2932
|
+
if (probe?.found !== true) return false;
|
|
2933
|
+
if (probe.html) return !explainedByCatchAll(site, probe.status);
|
|
2934
|
+
return !TEXT_TYPES.has(probe.contentType ?? "");
|
|
2935
|
+
},
|
|
2936
|
+
check: ({ site, issue }) => {
|
|
2937
|
+
const probe = site.llmsTxt;
|
|
2938
|
+
const served = probe.contentType ? `\`${probe.contentType}\`` : "no content type";
|
|
2939
|
+
const message = probe.html ? `\`/llms.txt\` answered ${probe.status} with an HTML page (${served}), not a markdown file. An agent that asks for the site's llms.txt is handed a page instead. If the site has no llms.txt the path should answer 404; if it has one, this is not the route serving it.` : `\`/llms.txt\` is served with ${served}. The file is markdown, read by agents and by people: \`text/plain\` or \`text/markdown\` say so, while anything else tells a client to treat it as something it is not \u2014 \`text/html\` renders it as one run-on paragraph, and a download type does not render it at all.`;
|
|
2940
|
+
return issue({ pageUrl: fileUrl(site), message, origin: { kind: "computed" } });
|
|
2941
|
+
}
|
|
2942
|
+
};
|
|
2943
|
+
var llmstxtH1Missing = {
|
|
2944
|
+
id: "llmstxt.h1.missing",
|
|
2945
|
+
severity: "warning",
|
|
2946
|
+
summary: "An llms.txt must have an H1 naming the site",
|
|
2947
|
+
rigor: "guideline",
|
|
2948
|
+
sources: ["llmstxt"],
|
|
2949
|
+
appliesTo: (site) => readable(site) && !site.llmsTxt.h1,
|
|
2950
|
+
check: ({ site, issue }) => issue({
|
|
2951
|
+
pageUrl: fileUrl(site),
|
|
2952
|
+
message: "`/llms.txt` has no H1 (`# Name`, with a space after the `#`). The proposal calls it the only required section \u2014 without it the file is not an llms.txt by its own definition, and a reader following the format has no name for what the rest describes.",
|
|
2953
|
+
origin: { kind: "computed" }
|
|
2954
|
+
})
|
|
2955
|
+
};
|
|
2956
|
+
function listedProbes(site) {
|
|
2957
|
+
const byUrl = site.llmsTxtLinks?.byUrl;
|
|
2958
|
+
if (!byUrl) return [];
|
|
2959
|
+
return sameOriginLinks(site).flatMap(({ url }) => byUrl.get(url) ?? []);
|
|
2960
|
+
}
|
|
2961
|
+
var llmstxtLinkUnreachable = {
|
|
2962
|
+
id: "llmstxt.link.unreachable",
|
|
2963
|
+
severity: "warning",
|
|
2964
|
+
summary: "Every same-origin URL an llms.txt lists must answer",
|
|
2965
|
+
rigor: "guideline",
|
|
2966
|
+
sources: ["llmstxt"],
|
|
2967
|
+
appliesTo: (site) => readable(site) && listedProbes(site).some((probe) => isDead(probe.status)),
|
|
2968
|
+
check: ({ site, issue }) => {
|
|
2969
|
+
const dead = listedProbes(site).filter((probe) => isDead(probe.status));
|
|
2970
|
+
const unprobed = site.llmsTxtLinks?.unprobed ?? 0;
|
|
2971
|
+
const floor = unprobed > 0 ? ` ${unprobed} further URL${unprobed === 1 ? " was" : "s were"} not checked \u2014 this count is a floor, not a total.` : "";
|
|
2972
|
+
return issue({
|
|
2973
|
+
pageUrl: fileUrl(site),
|
|
2974
|
+
message: `${dead.length} URL${dead.length === 1 ? "" : "s"} \`/llms.txt\` lists ${dead.length === 1 ? "does" : "do"} not answer: ${sample(dead.map((probe) => `${probe.url} (${probe.status === 0 ? "no response" : `HTTP ${probe.status}`})`))}. The file exists to send agents to these pages, so each dead one is a lookup that ends in an error instead of an answer.${floor}`,
|
|
2975
|
+
origin: { kind: "computed" }
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
};
|
|
2979
|
+
var llmstxtLinkBlockedByRobots = {
|
|
2980
|
+
id: "llmstxt.link.blocked-by-robots",
|
|
2981
|
+
severity: "warning",
|
|
2982
|
+
summary: "An llms.txt must not list URLs that robots.txt forbids fetching",
|
|
2983
|
+
rigor: "guideline",
|
|
2984
|
+
sources: ["llmstxt", "ietf-rfc9309", "anthropic-crawlers"],
|
|
2985
|
+
appliesTo: (site) => readable(site) && site.robots?.found === true && robotsAllows(site.robots.groups, "/").allowed,
|
|
2986
|
+
check: ({ site, issue }) => {
|
|
2987
|
+
const robots = site.robots;
|
|
2988
|
+
const file = fileUrl(site);
|
|
2989
|
+
const blocked = [];
|
|
2990
|
+
for (const url of [file, ...sameOriginLinks(site).map((link) => link.url)]) {
|
|
2991
|
+
let path;
|
|
2992
|
+
try {
|
|
2993
|
+
const parsed = new URL(url);
|
|
2994
|
+
path = `${parsed.pathname}${parsed.search}`;
|
|
2995
|
+
} catch {
|
|
2996
|
+
continue;
|
|
2997
|
+
}
|
|
2998
|
+
const decision = robotsAllows(robots.groups, path);
|
|
2999
|
+
if (decision.allowed || !decision.rule) continue;
|
|
3000
|
+
const self = url === file ? ", the file itself" : "";
|
|
3001
|
+
blocked.push(
|
|
3002
|
+
`${url} (line ${decision.rule.line}: Disallow: ${decision.rule.pattern}${self})`
|
|
3003
|
+
);
|
|
3004
|
+
}
|
|
3005
|
+
if (blocked.length === 0) return [];
|
|
3006
|
+
return issue({
|
|
3007
|
+
pageUrl: file,
|
|
3008
|
+
message: `${blocked.length} URL${blocked.length === 1 ? "" : "s"} \`/llms.txt\` points agents to ${blocked.length === 1 ? "is" : "are"} disallowed by \`robots.txt\` for \`User-agent: *\`: ${sample(blocked)}. The file offers them to agents, and an agent that honours robots.txt \u2014 Anthropic documents that Claude-User does \u2014 never fetches them.`,
|
|
3009
|
+
origin: { kind: "computed" }
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
};
|
|
3013
|
+
var LLMS_TXT_RULES = [
|
|
3014
|
+
llmstxtContentType,
|
|
3015
|
+
llmstxtH1Missing,
|
|
3016
|
+
llmstxtLinkBlockedByRobots,
|
|
3017
|
+
llmstxtLinkUnreachable,
|
|
3018
|
+
llmstxtUnreachable
|
|
3019
|
+
];
|
|
3020
|
+
|
|
3021
|
+
// src/lib/rules/sitemap-inventory.ts
|
|
3022
|
+
function sitemapReadInFull(site) {
|
|
3023
|
+
const discovery = site.discovery;
|
|
3024
|
+
if (discovery?.diagnostics.found !== true) return false;
|
|
3025
|
+
if (discovery.truncated || discovery.diagnostics.childSitemapErrors > 0) return false;
|
|
3026
|
+
const read = new Set(discovery.documents.map((doc) => resolved(doc.url)));
|
|
3027
|
+
const base = site.robots?.url ?? `${site.origin}/robots.txt`;
|
|
3028
|
+
return (site.robots?.sitemaps ?? []).every((declared) => {
|
|
3029
|
+
const url = resolved(declared.value, base);
|
|
3030
|
+
return url === void 0 || read.has(url);
|
|
3031
|
+
});
|
|
3032
|
+
}
|
|
3033
|
+
function resolved(value, base) {
|
|
3034
|
+
try {
|
|
3035
|
+
return new URL(value, base).href;
|
|
3036
|
+
} catch {
|
|
3037
|
+
return void 0;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
|
|
2832
3041
|
// src/lib/rules/site-rules.ts
|
|
2833
3042
|
function isMultilingual(site) {
|
|
2834
3043
|
return site.localeAxis.multilingual;
|
|
@@ -3037,6 +3246,69 @@ var iconsIcoMissing = {
|
|
|
3037
3246
|
});
|
|
3038
3247
|
}
|
|
3039
3248
|
};
|
|
3249
|
+
var httpNotFound = {
|
|
3250
|
+
id: "http.not-found",
|
|
3251
|
+
severity: "error",
|
|
3252
|
+
summary: "A URL that does not exist must answer 404 or 410",
|
|
3253
|
+
rigor: "vendor-spec",
|
|
3254
|
+
sources: ["ietf-rfc9110", "google-soft-404", "google-http-status"],
|
|
3255
|
+
appliesTo: (site) => (site.notFound ?? []).some((probe) => notFoundFailure(probe.status)),
|
|
3256
|
+
check: ({ site, issue }) => {
|
|
3257
|
+
const probes = site.notFound ?? [];
|
|
3258
|
+
const failed = probes.filter((probe) => notFoundFailure(probe.status));
|
|
3259
|
+
const held = probes.filter((probe) => isNotFound(probe.status));
|
|
3260
|
+
const answers = failed.map((probe) => {
|
|
3261
|
+
const path = pathOf(probe.url);
|
|
3262
|
+
const via = probe.finalUrl !== probe.url ? ` after redirecting to \`${probe.finalUrl}\`` : "";
|
|
3263
|
+
const type = probe.contentType ? ` \`${probe.contentType}\`` : "";
|
|
3264
|
+
return `\`${path}\` answered ${probe.status}${type}${via}`;
|
|
3265
|
+
});
|
|
3266
|
+
const contrast = held.length ? ` (${held.map((probe) => `\`${pathOf(probe.url)}\` answered ${probe.status}`).join(", ")}, so only some unknown paths are affected)` : "";
|
|
3267
|
+
const soft = failed.some((probe) => notFoundFailure(probe.status) === "soft");
|
|
3268
|
+
const crash = failed.some((probe) => notFoundFailure(probe.status) === "error");
|
|
3269
|
+
const consequences = [
|
|
3270
|
+
soft ? "A 2xx for a page that does not exist is a soft 404: every invented URL becomes a page to crawl, and a client asking for a file the site does not have \u2014 `/llms.txt`, `/feed.xml` \u2014 gets a page it may take for that file." : "",
|
|
3271
|
+
crash ? "A 5xx says the server failed, not that the page is missing: every mistyped link becomes a server error, and Google slows its crawl of the whole site in proportion to the URLs that return one." : ""
|
|
3272
|
+
].filter(Boolean);
|
|
3273
|
+
return issue({
|
|
3274
|
+
pageUrl: site.origin,
|
|
3275
|
+
message: `Asked for a path that cannot exist, the origin did not answer 404 or 410: ${answers.join("; ")}${contrast}. ${consequences.join(" ")}`,
|
|
3276
|
+
origin: { kind: "computed" },
|
|
3277
|
+
fix: {
|
|
3278
|
+
title: "Make the route that catches unknown paths call notFound()",
|
|
3279
|
+
snippet: [
|
|
3280
|
+
"// A dynamic first segment matches everything, `/anything.txt` included,",
|
|
3281
|
+
"// whenever a middleware matcher skips dotted paths. Validate it wherever",
|
|
3282
|
+
"// it is read \u2014 the layout, every page, every generateMetadata:",
|
|
3283
|
+
"const { locale } = await params;",
|
|
3284
|
+
"if (!routing.locales.includes(locale)) notFound();",
|
|
3285
|
+
"//",
|
|
3286
|
+
"// The layout alone is not enough in a production build: the page and its",
|
|
3287
|
+
"// metadata render alongside it, and whichever throws first sets the",
|
|
3288
|
+
"// status \u2014 a 500 from a page that formatted a date in locale `llms.txt`.",
|
|
3289
|
+
"//",
|
|
3290
|
+
"// A catch-all `[...slug]` must do the same for slugs it does not know,",
|
|
3291
|
+
"// and a static host's SPA fallback (`/* /index.html 200`) should be a",
|
|
3292
|
+
"// 404 page instead."
|
|
3293
|
+
].join("\n"),
|
|
3294
|
+
language: "ts"
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
};
|
|
3299
|
+
function notFoundFailure(status) {
|
|
3300
|
+
if (status >= 200 && status < 300) return "soft";
|
|
3301
|
+
if (status >= 500) return "error";
|
|
3302
|
+
return void 0;
|
|
3303
|
+
}
|
|
3304
|
+
function pathOf(url) {
|
|
3305
|
+
try {
|
|
3306
|
+
const parsed = new URL(url);
|
|
3307
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
3308
|
+
} catch {
|
|
3309
|
+
return url;
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3040
3312
|
var ROBOTS_BYTE_LIMIT = 500 * 1024;
|
|
3041
3313
|
var robotstxtUnreachable = {
|
|
3042
3314
|
id: "robotstxt.unreachable",
|
|
@@ -3172,12 +3444,6 @@ var robotsBlocksPage = {
|
|
|
3172
3444
|
}
|
|
3173
3445
|
};
|
|
3174
3446
|
var CHANGEFREQ = /* @__PURE__ */ new Set(["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
|
|
3175
|
-
var SAMPLE = 5;
|
|
3176
|
-
function sample(locs) {
|
|
3177
|
-
const shown = locs.slice(0, SAMPLE);
|
|
3178
|
-
const rest = locs.length - shown.length;
|
|
3179
|
-
return `${shown.map((l) => `\`${l}\``).join(", ")}${rest > 0 ? `, and ${rest} more` : ""}`;
|
|
3180
|
-
}
|
|
3181
3447
|
var sitemapMissing = {
|
|
3182
3448
|
id: "sitemap.missing",
|
|
3183
3449
|
severity: "warning",
|
|
@@ -3494,16 +3760,43 @@ function sameUrlKey(url) {
|
|
|
3494
3760
|
}
|
|
3495
3761
|
function pagesByUrl(site) {
|
|
3496
3762
|
const byUrl = /* @__PURE__ */ new Map();
|
|
3497
|
-
for (const page of site.pages) {
|
|
3763
|
+
for (const page of [...site.pages, ...site.variants ?? []]) {
|
|
3498
3764
|
const key = sameUrlKey(page.fetch.finalUrl);
|
|
3499
3765
|
if (key) byUrl.set(key, page);
|
|
3500
3766
|
}
|
|
3501
3767
|
return byUrl;
|
|
3502
3768
|
}
|
|
3769
|
+
var NOINDEX = /* @__PURE__ */ new Set(["noindex", "none"]);
|
|
3770
|
+
var VALUED_DIRECTIVES = /* @__PURE__ */ new Set([
|
|
3771
|
+
"max-snippet",
|
|
3772
|
+
"max-image-preview",
|
|
3773
|
+
"max-video-preview",
|
|
3774
|
+
"unavailable_after"
|
|
3775
|
+
]);
|
|
3776
|
+
function headerDirectiveNames(value) {
|
|
3777
|
+
if (!value) return [];
|
|
3778
|
+
return value.split(",").map((token) => {
|
|
3779
|
+
let directive = token.trim().toLowerCase();
|
|
3780
|
+
const colon = directive.indexOf(":");
|
|
3781
|
+
if (colon !== -1 && !VALUED_DIRECTIVES.has(directive.slice(0, colon).trim())) {
|
|
3782
|
+
directive = directive.slice(colon + 1);
|
|
3783
|
+
}
|
|
3784
|
+
return directive.split(":")[0].trim();
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3503
3787
|
function saysNoindex(page) {
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
return
|
|
3788
|
+
const metas = [page.meta.robots?.value, page.meta.googlebot?.value];
|
|
3789
|
+
if (metas.some((content) => (content ?? "").split(",").some(isNoindex))) return true;
|
|
3790
|
+
return headerDirectiveNames(page.fetch.headers["x-robots-tag"]).some(isNoindex);
|
|
3791
|
+
}
|
|
3792
|
+
function isNoindex(directive) {
|
|
3793
|
+
return NOINDEX.has(directive.trim().toLowerCase());
|
|
3794
|
+
}
|
|
3795
|
+
function canonicalElsewhere(page, self = sameUrlKey(page.fetch.finalUrl)) {
|
|
3796
|
+
const canonical = page.links.canonical;
|
|
3797
|
+
if (!canonical) return false;
|
|
3798
|
+
const target = sameUrlKey(canonical);
|
|
3799
|
+
return target !== void 0 && target !== self;
|
|
3507
3800
|
}
|
|
3508
3801
|
var sitemapEntryBlockedByRobots = {
|
|
3509
3802
|
id: "sitemap.entry.blocked-by-robots",
|
|
@@ -3574,17 +3867,14 @@ var sitemapEntryNonCanonical = {
|
|
|
3574
3867
|
for (const entry of site.discovery.urls) {
|
|
3575
3868
|
const key = sameUrlKey(entry.loc);
|
|
3576
3869
|
const page = key ? byUrl.get(key) : void 0;
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
const canonicalKey = sameUrlKey(canonical);
|
|
3580
|
-
if (canonicalKey && canonicalKey !== key) {
|
|
3581
|
-
wrong.push(`${entry.loc} \u2192 ${canonical}`);
|
|
3870
|
+
if (page && canonicalElsewhere(page)) {
|
|
3871
|
+
wrong.push(`${entry.loc} \u2192 ${page.links.canonical}`);
|
|
3582
3872
|
}
|
|
3583
3873
|
}
|
|
3584
3874
|
if (wrong.length === 0) return [];
|
|
3585
3875
|
return issue({
|
|
3586
3876
|
pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
|
|
3587
|
-
message: `${wrong.length} sitemap entr${wrong.length === 1 ? "y names a page whose canonical" : "ies name pages whose canonicals"}
|
|
3877
|
+
message: `${wrong.length} sitemap entr${wrong.length === 1 ? "y names a page whose canonical points" : "ies name pages whose canonicals point"} elsewhere: ${sample(wrong)}. The sitemap is a list of what to index, so it should name the URL the site itself prefers.`,
|
|
3588
3878
|
origin: { kind: "computed" }
|
|
3589
3879
|
});
|
|
3590
3880
|
}
|
|
@@ -3592,26 +3882,96 @@ var sitemapEntryNonCanonical = {
|
|
|
3592
3882
|
var sitemapOrphans = {
|
|
3593
3883
|
id: "sitemap.orphans",
|
|
3594
3884
|
severity: "warning",
|
|
3595
|
-
summary: "Indexable pages the crawl found should be listed in the sitemap",
|
|
3885
|
+
summary: "Indexable pages the crawl found, and the canonical URLs they name, should be listed in the sitemap",
|
|
3596
3886
|
rigor: "guideline",
|
|
3597
3887
|
sources: ["sitemaps-protocol", "google-sitemaps"],
|
|
3598
3888
|
// One finding with a count and a sample, not one per page — the same shape
|
|
3599
3889
|
// translation holes take, and for the same reason: forty repeats of one
|
|
3600
3890
|
// omission is noise, and the omission is a property of the sitemap.
|
|
3601
|
-
|
|
3891
|
+
//
|
|
3892
|
+
// Judged only against an inventory goflag read in full — every sitemap the
|
|
3893
|
+
// site declares, none of them cut short (`./sitemap-inventory.ts`). A page
|
|
3894
|
+
// listed where goflag could not read looks exactly like one the site forgot.
|
|
3895
|
+
// What stopped the read is its own finding where one exists
|
|
3896
|
+
// (`sitemap.index.child-error`); this one waits until the list is whole.
|
|
3897
|
+
appliesTo: (site) => sitemapReadInFull(site) && site.discovery.urls.length > 0,
|
|
3602
3898
|
check: ({ site, issue }) => {
|
|
3603
|
-
const listed =
|
|
3604
|
-
|
|
3605
|
-
);
|
|
3606
|
-
const
|
|
3607
|
-
|
|
3899
|
+
const { listed, fetched, unfollowed } = sitemapReach(site);
|
|
3900
|
+
const orphans = [];
|
|
3901
|
+
const unaudited = /* @__PURE__ */ new Map();
|
|
3902
|
+
for (const page of site.pages) {
|
|
3903
|
+
if (saysNoindex(page)) continue;
|
|
3904
|
+
const key = sameUrlKey(page.fetch.finalUrl);
|
|
3905
|
+
if (key === void 0) continue;
|
|
3906
|
+
if (!canonicalElsewhere(page, key)) {
|
|
3907
|
+
if (!listed.has(key)) orphans.push(page.fetch.finalUrl);
|
|
3908
|
+
continue;
|
|
3909
|
+
}
|
|
3910
|
+
const target = page.links.canonical;
|
|
3911
|
+
const targetKey2 = sameUrlKey(target);
|
|
3912
|
+
if (sameOrigin3(target, site.origin) && !listed.has(targetKey2) && !fetched.has(targetKey2) && !unaudited.has(targetKey2)) {
|
|
3913
|
+
unaudited.set(targetKey2, { url: target, namedBy: page.fetch.finalUrl });
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
const named = [...unaudited.values()];
|
|
3917
|
+
const total = orphans.length + named.length;
|
|
3918
|
+
if (total === 0) return [];
|
|
3919
|
+
const said = [];
|
|
3920
|
+
if (orphans.length > 0) {
|
|
3921
|
+
said.push(
|
|
3922
|
+
`${orphans.length} crawled page${orphans.length === 1 ? "" : "s"} ask${orphans.length === 1 ? "s" : ""} to be indexed and ${orphans.length === 1 ? "is" : "are"} absent from the sitemap: ${sample(orphans)}.`
|
|
3923
|
+
);
|
|
3924
|
+
}
|
|
3925
|
+
if (named.length > 0) {
|
|
3926
|
+
said.push(
|
|
3927
|
+
`${named.length} URL${named.length === 1 ? "" : "s"} that crawled pages name as their canonical ${named.length === 1 ? "is" : "are"} absent from ${orphans.length > 0 ? "it too" : "the sitemap"}, and ${named.length === 1 ? "was" : "were"} not audited: ${sample(named.map((t) => `${t.url} (canonical of ${t.namedBy})`))}.`
|
|
3928
|
+
);
|
|
3929
|
+
}
|
|
3608
3930
|
return issue({
|
|
3609
3931
|
pageUrl: site.discovery.diagnostics.sitemapUrl ?? `${site.origin}/sitemap.xml`,
|
|
3610
|
-
message: `${
|
|
3932
|
+
message: `${said.join(" ")} A consumer that reads the sitemap rather than following links will never see ${total === 1 ? "it" : "them"}.${ceilingNote(unfollowed)}`,
|
|
3611
3933
|
origin: { kind: "computed" }
|
|
3612
3934
|
});
|
|
3613
3935
|
}
|
|
3614
3936
|
};
|
|
3937
|
+
function sameOrigin3(a, b) {
|
|
3938
|
+
try {
|
|
3939
|
+
return new URL(a).origin === new URL(b).origin;
|
|
3940
|
+
} catch {
|
|
3941
|
+
return false;
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
function sitemapReach(site) {
|
|
3945
|
+
const fetched = /* @__PURE__ */ new Set();
|
|
3946
|
+
const landingOf = /* @__PURE__ */ new Map();
|
|
3947
|
+
for (const page of [...site.pages, ...site.variants ?? []]) {
|
|
3948
|
+
const final = sameUrlKey(page.fetch.finalUrl);
|
|
3949
|
+
const requested = sameUrlKey(page.fetch.requestedUrl);
|
|
3950
|
+
if (!final) continue;
|
|
3951
|
+
fetched.add(final);
|
|
3952
|
+
if (requested && requested !== final) landingOf.set(requested, final);
|
|
3953
|
+
}
|
|
3954
|
+
for (const probe of entryProbes(site)) {
|
|
3955
|
+
const from = sameUrlKey(probe.url);
|
|
3956
|
+
const to = probe.redirected ? sameUrlKey(probe.finalUrl) : void 0;
|
|
3957
|
+
if (from && to) landingOf.set(from, to);
|
|
3958
|
+
}
|
|
3959
|
+
const listed = /* @__PURE__ */ new Set();
|
|
3960
|
+
let unfollowed = 0;
|
|
3961
|
+
for (const entry of site.discovery.urls) {
|
|
3962
|
+
const key = sameUrlKey(entry.loc);
|
|
3963
|
+
if (!key) continue;
|
|
3964
|
+
listed.add(key);
|
|
3965
|
+
const landing = landingOf.get(key);
|
|
3966
|
+
if (landing) listed.add(landing);
|
|
3967
|
+
else if (!fetched.has(key) && !site.sitemapEntries?.byUrl.has(entry.loc)) unfollowed += 1;
|
|
3968
|
+
}
|
|
3969
|
+
return { listed, fetched, unfollowed };
|
|
3970
|
+
}
|
|
3971
|
+
function ceilingNote(unfollowed) {
|
|
3972
|
+
if (unfollowed === 0) return "";
|
|
3973
|
+
return ` ${unfollowed} sitemap entr${unfollowed === 1 ? "y was" : "ies were"} not fetched, and a page one of them redirects to would be counted here \u2014 this count is a ceiling, not a total.`;
|
|
3974
|
+
}
|
|
3615
3975
|
function entryProbes(site) {
|
|
3616
3976
|
return [...site.sitemapEntries?.byUrl.values() ?? []];
|
|
3617
3977
|
}
|
|
@@ -3652,9 +4012,6 @@ var sitemapEntryRedirects = {
|
|
|
3652
4012
|
});
|
|
3653
4013
|
}
|
|
3654
4014
|
};
|
|
3655
|
-
function isDead(status) {
|
|
3656
|
-
return status === 0 || status >= 400;
|
|
3657
|
-
}
|
|
3658
4015
|
function isRobotsFailure(status) {
|
|
3659
4016
|
return status === 0 || status >= 500;
|
|
3660
4017
|
}
|
|
@@ -3669,7 +4026,9 @@ function isAbsolute(value) {
|
|
|
3669
4026
|
var SITE_RULES = [
|
|
3670
4027
|
hreflangMissing,
|
|
3671
4028
|
hreflangClusterIncomplete,
|
|
4029
|
+
httpNotFound,
|
|
3672
4030
|
iconsIcoMissing,
|
|
4031
|
+
...LLMS_TXT_RULES,
|
|
3673
4032
|
robotsBlocksPage,
|
|
3674
4033
|
robotsBlocksSite,
|
|
3675
4034
|
robotstxtCrossOrigin,
|
|
@@ -4498,7 +4857,7 @@ function schemeOf(url) {
|
|
|
4498
4857
|
}
|
|
4499
4858
|
}
|
|
4500
4859
|
function defaultSleep(ms) {
|
|
4501
|
-
return new Promise((
|
|
4860
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
4502
4861
|
}
|
|
4503
4862
|
|
|
4504
4863
|
// src/lib/core/sitemap/entries.ts
|
|
@@ -4919,6 +5278,86 @@ function isIconType(contentType) {
|
|
|
4919
5278
|
return contentType.startsWith("image/") || contentType === "application/ico" || contentType === "application/x-ico";
|
|
4920
5279
|
}
|
|
4921
5280
|
|
|
5281
|
+
// src/lib/core/llmstxt/parse.ts
|
|
5282
|
+
var H1 = /^ {0,3}#(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$/;
|
|
5283
|
+
var FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
5284
|
+
var LINK = /(!?)\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
|
|
5285
|
+
function parseLlmsTxt(body, fileUrl2) {
|
|
5286
|
+
const lines = body.replace(/^\uFEFF/, "").split(/\r\n|\r|\n/);
|
|
5287
|
+
const links = [];
|
|
5288
|
+
let h1;
|
|
5289
|
+
let fence;
|
|
5290
|
+
for (const [index, raw] of lines.entries()) {
|
|
5291
|
+
const line = index + 1;
|
|
5292
|
+
const fenceMatch = FENCE.exec(raw);
|
|
5293
|
+
if (fenceMatch) {
|
|
5294
|
+
const marker = fenceMatch[1];
|
|
5295
|
+
if (fence === void 0) fence = marker;
|
|
5296
|
+
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = void 0;
|
|
5297
|
+
continue;
|
|
5298
|
+
}
|
|
5299
|
+
if (fence !== void 0) continue;
|
|
5300
|
+
if (!h1) {
|
|
5301
|
+
const heading = H1.exec(raw);
|
|
5302
|
+
const text = heading?.[1]?.trim();
|
|
5303
|
+
if (text) h1 = { text, line };
|
|
5304
|
+
}
|
|
5305
|
+
const prose = raw.replace(/`[^`]*`/g, "");
|
|
5306
|
+
for (const match of prose.matchAll(LINK)) {
|
|
5307
|
+
if (match[1] === "!") continue;
|
|
5308
|
+
const url = resolve(match[3], fileUrl2);
|
|
5309
|
+
if (url) links.push({ name: match[2].trim(), url, line });
|
|
5310
|
+
}
|
|
5311
|
+
}
|
|
5312
|
+
return { ...h1 ? { h1 } : {}, links };
|
|
5313
|
+
}
|
|
5314
|
+
function looksLikeHtml(body) {
|
|
5315
|
+
return /^(?:<!doctype\s+html|<html[\s>])/i.test(body.replace(/^\uFEFF/, "").trimStart());
|
|
5316
|
+
}
|
|
5317
|
+
function resolve(href, base) {
|
|
5318
|
+
try {
|
|
5319
|
+
const url = new URL(href, base);
|
|
5320
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
5321
|
+
url.hash = "";
|
|
5322
|
+
return url.toString();
|
|
5323
|
+
} catch {
|
|
5324
|
+
return void 0;
|
|
5325
|
+
}
|
|
5326
|
+
}
|
|
5327
|
+
|
|
5328
|
+
// src/lib/core/probes/llms-txt.ts
|
|
5329
|
+
async function probeLlmsTxt(origin, options = {}) {
|
|
5330
|
+
const url = new URL("/llms.txt", origin).toString();
|
|
5331
|
+
const { signal, cleanup } = combineSignals(options.signal, options.timeoutMs ?? 5e3);
|
|
5332
|
+
const nothing = { found: false, html: false, links: [] };
|
|
5333
|
+
try {
|
|
5334
|
+
const res = await fetch(url, { signal, redirect: "follow" });
|
|
5335
|
+
const finalUrl = res.url || url;
|
|
5336
|
+
const contentType = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
5337
|
+
if (!res.ok) {
|
|
5338
|
+
await res.body?.cancel().catch(() => void 0);
|
|
5339
|
+
return { url, status: res.status, finalUrl, contentType, ...nothing };
|
|
5340
|
+
}
|
|
5341
|
+
const body = await res.text();
|
|
5342
|
+
if (looksLikeHtml(body)) {
|
|
5343
|
+
return { url, status: res.status, finalUrl, contentType, found: true, html: true, links: [] };
|
|
5344
|
+
}
|
|
5345
|
+
return {
|
|
5346
|
+
url,
|
|
5347
|
+
status: res.status,
|
|
5348
|
+
finalUrl,
|
|
5349
|
+
contentType,
|
|
5350
|
+
found: true,
|
|
5351
|
+
html: false,
|
|
5352
|
+
...parseLlmsTxt(body, finalUrl)
|
|
5353
|
+
};
|
|
5354
|
+
} catch {
|
|
5355
|
+
return { url, status: 0, finalUrl: url, ...nothing };
|
|
5356
|
+
} finally {
|
|
5357
|
+
cleanup();
|
|
5358
|
+
}
|
|
5359
|
+
}
|
|
5360
|
+
|
|
4922
5361
|
// src/lib/rules/advisory.ts
|
|
4923
5362
|
function resolveEvidencePath(extraction, path) {
|
|
4924
5363
|
let current = extraction;
|
|
@@ -5087,7 +5526,12 @@ var hreflangSitemapMismatch = {
|
|
|
5087
5526
|
summary: "`<head>` alternates advertise a locale the sitemap does not list",
|
|
5088
5527
|
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
5528
|
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
|
-
|
|
5529
|
+
// A question about what the sitemap *omits*, so it waits for a sitemap read in
|
|
5530
|
+
// full (`./sitemap-inventory.ts`): a translation listed in a child goflag
|
|
5531
|
+
// could not read, or in a second declared sitemap it never opened, would be
|
|
5532
|
+
// asked about as missing — a question with a false premise wastes an agent's
|
|
5533
|
+
// turn as surely as a false finding wastes a human's.
|
|
5534
|
+
appliesTo: (site) => site.localeAxis.multilingual && (site.discovery?.urls.length ?? 0) > 0 && sitemapReadInFull(site),
|
|
5091
5535
|
ask: (site) => {
|
|
5092
5536
|
const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
|
|
5093
5537
|
const bySitemap = /* @__PURE__ */ new Map();
|
|
@@ -5400,9 +5844,9 @@ function hostOf(url) {
|
|
|
5400
5844
|
function mapWithHostCaps(items, opts, worker, hostOf3) {
|
|
5401
5845
|
const concurrency = Math.max(1, opts.concurrency);
|
|
5402
5846
|
const perHost = opts.perHost;
|
|
5403
|
-
return new Promise((
|
|
5847
|
+
return new Promise((resolve3) => {
|
|
5404
5848
|
if (items.length === 0) {
|
|
5405
|
-
|
|
5849
|
+
resolve3();
|
|
5406
5850
|
return;
|
|
5407
5851
|
}
|
|
5408
5852
|
const pending = items.map((item) => ({ item, host: hostOf3(item) }));
|
|
@@ -5412,7 +5856,7 @@ function mapWithHostCaps(items, opts, worker, hostOf3) {
|
|
|
5412
5856
|
const finish = () => {
|
|
5413
5857
|
if (!settled) {
|
|
5414
5858
|
settled = true;
|
|
5415
|
-
|
|
5859
|
+
resolve3();
|
|
5416
5860
|
}
|
|
5417
5861
|
};
|
|
5418
5862
|
const pump = () => {
|
|
@@ -5578,14 +6022,14 @@ async function attachManifests(pages, options) {
|
|
|
5578
6022
|
}
|
|
5579
6023
|
}
|
|
5580
6024
|
async function probeAssets(pages, options) {
|
|
5581
|
-
const
|
|
6025
|
+
const sameOrigin4 = (url) => {
|
|
5582
6026
|
try {
|
|
5583
6027
|
return new URL(url).origin === new URL(options.origin).origin;
|
|
5584
6028
|
} catch {
|
|
5585
6029
|
return false;
|
|
5586
6030
|
}
|
|
5587
6031
|
};
|
|
5588
|
-
const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal ||
|
|
6032
|
+
const wantedUrl = (url) => /^https?:\/\//i.test(url) && (options.checkExternal || sameOrigin4(url));
|
|
5589
6033
|
const wanted = /* @__PURE__ */ new Set();
|
|
5590
6034
|
for (const page of pages) {
|
|
5591
6035
|
for (const image of page.openGraph.images) {
|
|
@@ -5639,7 +6083,8 @@ function dropCanonicalDuplicates(pages) {
|
|
|
5639
6083
|
if (target === self) return true;
|
|
5640
6084
|
return !crawled.has(target);
|
|
5641
6085
|
});
|
|
5642
|
-
|
|
6086
|
+
const keptSet = new Set(kept);
|
|
6087
|
+
return { kept, variants: pages.filter((page) => !keptSet.has(page)) };
|
|
5643
6088
|
}
|
|
5644
6089
|
async function runAudit(inputUrl, options = {}) {
|
|
5645
6090
|
const normalized = normalizeInputUrl(inputUrl);
|
|
@@ -5673,6 +6118,14 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5673
6118
|
signal: options.signal,
|
|
5674
6119
|
timeoutMs: options.timeoutMs
|
|
5675
6120
|
}).catch(() => void 0);
|
|
6121
|
+
const [notFound, llmsTxt] = await Promise.all([
|
|
6122
|
+
probeNotFound(origin, { signal: options.signal, timeoutMs: options.timeoutMs }).catch(
|
|
6123
|
+
() => void 0
|
|
6124
|
+
),
|
|
6125
|
+
probeLlmsTxt(origin, { signal: options.signal, timeoutMs: options.timeoutMs }).catch(
|
|
6126
|
+
() => void 0
|
|
6127
|
+
)
|
|
6128
|
+
]);
|
|
5676
6129
|
const crawlResult = await crawl({
|
|
5677
6130
|
entryUrl: entry,
|
|
5678
6131
|
seedUrls: sitemapUrls,
|
|
@@ -5706,7 +6159,8 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5706
6159
|
if (escalationWarning) warnings.push(escalationWarning);
|
|
5707
6160
|
const okPages = pages.filter((p) => isOkStatus(p.fetch.status));
|
|
5708
6161
|
const documents = okPages.filter(isHtmlPage);
|
|
5709
|
-
const { kept: htmlPages,
|
|
6162
|
+
const { kept: htmlPages, variants } = dropCanonicalDuplicates(documents);
|
|
6163
|
+
const duplicatePages = variants.length;
|
|
5710
6164
|
const unreachablePages = [
|
|
5711
6165
|
...pages.filter((p) => !isOkStatus(p.fetch.status)).map((p) => ({
|
|
5712
6166
|
id: fingerprint("page", routeKey(p.fetch.finalUrl)),
|
|
@@ -5830,18 +6284,23 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5830
6284
|
})
|
|
5831
6285
|
});
|
|
5832
6286
|
warnings.push(...linkReport.diagnostics.warnings);
|
|
6287
|
+
const answered = {
|
|
6288
|
+
crawled: new Map(crawlResult.pages.map((page) => [page.fetch.finalUrl, page.fetch.status])),
|
|
6289
|
+
checked: new Map(Object.values(linkReport.checks).map((check) => [check.url, check])),
|
|
6290
|
+
maxProbes: effectiveMaxPages,
|
|
6291
|
+
signal: options.signal,
|
|
6292
|
+
timeoutMs: options.timeoutMs,
|
|
6293
|
+
allowInsecureTls: options.allowInsecureTls
|
|
6294
|
+
};
|
|
5833
6295
|
const sitemapEntries = discovery ? await probeSitemapEntries(
|
|
5834
6296
|
discovery.urls.map((entry2) => entry2.loc),
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
timeoutMs: options.timeoutMs,
|
|
5843
|
-
allowInsecureTls: options.allowInsecureTls
|
|
5844
|
-
}
|
|
6297
|
+
answered
|
|
6298
|
+
) : void 0;
|
|
6299
|
+
const llmsTxtLinks = llmsTxt?.found && !llmsTxt.html ? await probeSitemapEntries(
|
|
6300
|
+
[...new Set(llmsTxt.links.map((link) => link.url))].filter(
|
|
6301
|
+
(url) => sameOriginAs(url, origin)
|
|
6302
|
+
),
|
|
6303
|
+
answered
|
|
5845
6304
|
) : void 0;
|
|
5846
6305
|
const siteContext = {
|
|
5847
6306
|
origin,
|
|
@@ -5851,7 +6310,14 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5851
6310
|
discovery,
|
|
5852
6311
|
robots,
|
|
5853
6312
|
favicon: favicon2,
|
|
6313
|
+
notFound,
|
|
6314
|
+
llmsTxt,
|
|
6315
|
+
llmsTxtLinks,
|
|
5854
6316
|
sitemapEntries,
|
|
6317
|
+
// Set aside above, and still what their URLs serve. The sitemap rules that
|
|
6318
|
+
// ask what a listed URL serves need them: a listed variant is exactly what
|
|
6319
|
+
// `sitemap.entry.non-canonical` exists to catch.
|
|
6320
|
+
variants,
|
|
5855
6321
|
// Same index the matrix was built from, so a rule that groups by route
|
|
5856
6322
|
// and the grid that reports holes cannot disagree about which URLs are
|
|
5857
6323
|
// one page.
|
|
@@ -6014,6 +6480,13 @@ function countUncrawled(sitemapUrls, pages) {
|
|
|
6014
6480
|
for (const url of sitemapUrls) if (!crawled.has(url)) n += 1;
|
|
6015
6481
|
return n;
|
|
6016
6482
|
}
|
|
6483
|
+
function sameOriginAs(url, origin) {
|
|
6484
|
+
try {
|
|
6485
|
+
return new URL(url).origin === origin;
|
|
6486
|
+
} catch {
|
|
6487
|
+
return false;
|
|
6488
|
+
}
|
|
6489
|
+
}
|
|
6017
6490
|
function syntheticDiscovery(origin, baseUrl, pages, truncated) {
|
|
6018
6491
|
return {
|
|
6019
6492
|
origin,
|
|
@@ -6436,7 +6909,7 @@ function hostOf2(value) {
|
|
|
6436
6909
|
return value;
|
|
6437
6910
|
}
|
|
6438
6911
|
}
|
|
6439
|
-
function
|
|
6912
|
+
function pathOf2(value) {
|
|
6440
6913
|
try {
|
|
6441
6914
|
const { pathname, search } = new URL(value);
|
|
6442
6915
|
return `${pathname}${search}`;
|
|
@@ -6465,7 +6938,7 @@ function originLabel(origin) {
|
|
|
6465
6938
|
return "computed";
|
|
6466
6939
|
}
|
|
6467
6940
|
}
|
|
6468
|
-
function
|
|
6941
|
+
function resolve2(...facts) {
|
|
6469
6942
|
for (const fact of facts) {
|
|
6470
6943
|
if (fact && fact.value.trim() !== "") {
|
|
6471
6944
|
return { value: fact.value, from: originLabel(fact.origin) };
|
|
@@ -6509,13 +6982,13 @@ function cardOf(extraction) {
|
|
|
6509
6982
|
return {
|
|
6510
6983
|
url,
|
|
6511
6984
|
host: hostOf2(url),
|
|
6512
|
-
path:
|
|
6513
|
-
ogTitle:
|
|
6514
|
-
ogDescription:
|
|
6515
|
-
documentTitle:
|
|
6516
|
-
metaDescription:
|
|
6517
|
-
siteName:
|
|
6518
|
-
xTitle:
|
|
6985
|
+
path: pathOf2(url),
|
|
6986
|
+
ogTitle: resolve2(og.title, doc.title),
|
|
6987
|
+
ogDescription: resolve2(og.description, meta.description),
|
|
6988
|
+
documentTitle: resolve2(doc.title),
|
|
6989
|
+
metaDescription: resolve2(meta.description),
|
|
6990
|
+
siteName: resolve2(og.siteName),
|
|
6991
|
+
xTitle: resolve2(twitter.title, og.title, doc.title),
|
|
6519
6992
|
image: imageOf(extraction),
|
|
6520
6993
|
icon: safeUrl(extraction.links.icons[0]?.href),
|
|
6521
6994
|
themeColor: meta.themeColor?.value,
|
|
@@ -6768,6 +7241,57 @@ function translationsPanel(current, siblings) {
|
|
|
6768
7241
|
</table></div>
|
|
6769
7242
|
<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
7243
|
}
|
|
7244
|
+
function pathSegments(path) {
|
|
7245
|
+
const cut = path.indexOf("?");
|
|
7246
|
+
const pathname = cut === -1 ? path : path.slice(0, cut);
|
|
7247
|
+
const search = cut === -1 ? "" : path.slice(cut);
|
|
7248
|
+
const segments2 = pathname.split("/").filter(Boolean);
|
|
7249
|
+
if (search === "") return segments2;
|
|
7250
|
+
return segments2.length === 0 ? [search] : [...segments2.slice(0, -1), `${segments2.at(-1)}${search}`];
|
|
7251
|
+
}
|
|
7252
|
+
function buildRouteTree(paths) {
|
|
7253
|
+
const root = { segments: [], children: /* @__PURE__ */ new Map(), count: 0 };
|
|
7254
|
+
paths.forEach((path, index) => {
|
|
7255
|
+
let node = root;
|
|
7256
|
+
node.count++;
|
|
7257
|
+
for (const segment of pathSegments(path)) {
|
|
7258
|
+
const child = node.children.get(segment) ?? {
|
|
7259
|
+
segments: [segment],
|
|
7260
|
+
children: /* @__PURE__ */ new Map(),
|
|
7261
|
+
count: 0
|
|
7262
|
+
};
|
|
7263
|
+
node.children.set(segment, child);
|
|
7264
|
+
child.count++;
|
|
7265
|
+
node = child;
|
|
7266
|
+
}
|
|
7267
|
+
node.index ??= index;
|
|
7268
|
+
});
|
|
7269
|
+
return { ...root, children: foldChildren(root.children) };
|
|
7270
|
+
}
|
|
7271
|
+
function foldChildren(children) {
|
|
7272
|
+
return new Map([...children].map(([key, node]) => [key, foldChain(node)]));
|
|
7273
|
+
}
|
|
7274
|
+
function foldChain(node) {
|
|
7275
|
+
let current = node;
|
|
7276
|
+
while (current.index === void 0 && current.children.size === 1) {
|
|
7277
|
+
const only = [...current.children.values()][0];
|
|
7278
|
+
current = { ...only, segments: [...current.segments, ...only.segments] };
|
|
7279
|
+
}
|
|
7280
|
+
return { ...current, children: foldChildren(current.children) };
|
|
7281
|
+
}
|
|
7282
|
+
function routeList(node, open, className = "") {
|
|
7283
|
+
const items = [...node.children.values()].map((child) => {
|
|
7284
|
+
const label = esc(child.segments.map((segment) => `/${segment}`).join(""));
|
|
7285
|
+
if (child.children.size === 0) {
|
|
7286
|
+
return child.index === void 0 ? `<li class="leaf"><span>${label}</span></li>` : `<li class="leaf"><a href="#page-${child.index}">${label}</a></li>`;
|
|
7287
|
+
}
|
|
7288
|
+
const self = child.index === void 0 ? `<span class="go"></span>` : `<a class="go" href="#page-${child.index}" title="the page at this path">\u2197</a>`;
|
|
7289
|
+
return `<li><details${open ? " open" : ""}><summary><span class="seg">${label}</span><span class="n">${child.count}</span>${self}</summary>
|
|
7290
|
+
${routeList(child, open)}</details></li>`;
|
|
7291
|
+
});
|
|
7292
|
+
return `<ul${className ? ` class="${className}"` : ""}>${items.join("\n")}</ul>`;
|
|
7293
|
+
}
|
|
7294
|
+
var TREE_OPEN_MAX = 20;
|
|
6771
7295
|
function tagLabel(tag) {
|
|
6772
7296
|
const key = tag.property ?? tag.name ?? (tag.httpEquiv ? `http-equiv=${tag.httpEquiv}` : void 0);
|
|
6773
7297
|
if (key) return `${key}${tag.content ? ` = ${clip(tag.content, 120)}` : ""}`;
|
|
@@ -6837,9 +7361,21 @@ h1,h2,h3,.badge,.chip,.rid,.src,.from,.mono,code,pre,dt,.pin,.nav{font-family:va
|
|
|
6837
7361
|
h1{font-size:22px;letter-spacing:-.02em;margin:0 0 6px}
|
|
6838
7362
|
.lede{color:var(--ink2);margin:0;max-width:70ch}
|
|
6839
7363
|
header.doc{padding:44px 0 24px;border-bottom:1px solid var(--line)}
|
|
6840
|
-
.nav{
|
|
6841
|
-
.
|
|
6842
|
-
.
|
|
7364
|
+
.nav{margin:22px 0 0;max-width:620px}
|
|
7365
|
+
.navnote{margin:0 0 8px;font-family:var(--ui);font-size:12px;color:var(--ink3)}
|
|
7366
|
+
.tree,.tree ul{list-style:none;margin:0;padding:0;font-size:12px}
|
|
7367
|
+
.tree ul{margin-left:6px;padding-left:12px;border-left:1px solid var(--line)}
|
|
7368
|
+
.tree summary{display:flex;align-items:center;gap:10px;padding:3px 7px;border-radius:3px;color:var(--ink2);cursor:pointer;list-style:none}
|
|
7369
|
+
.tree summary::-webkit-details-marker{display:none}
|
|
7370
|
+
.tree summary::before{content:"\u25B8";flex:none;width:1ch;font-size:9px;color:var(--ink3)}
|
|
7371
|
+
.tree details[open]>summary::before{content:"\u25BE"}
|
|
7372
|
+
.tree summary:hover,.tree summary:focus-visible{background:var(--wash);color:var(--ink)}
|
|
7373
|
+
.tree .seg{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
7374
|
+
.tree .n{margin-left:auto;flex:none;font-size:10px;color:var(--ink3);font-variant-numeric:tabular-nums}
|
|
7375
|
+
.tree .go{flex:none;width:1.2em;text-align:right;font-size:11px;text-decoration:none;opacity:.55}
|
|
7376
|
+
.tree .go:hover,.tree .go:focus-visible{opacity:1}
|
|
7377
|
+
.tree .leaf a,.tree .leaf span{display:block;padding:3px 7px 3px 24px;color:var(--ink2);text-decoration:none;border-radius:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
7378
|
+
.tree .leaf a:hover,.tree .leaf a:focus-visible{background:var(--wash);color:var(--brand)}
|
|
6843
7379
|
.page{padding-top:52px}
|
|
6844
7380
|
.pagehead h2{font-size:16px;margin:0 0 4px}
|
|
6845
7381
|
.pagehead .url{margin:0;font-size:13px;color:var(--ink2);word-break:break-all}
|
|
@@ -6955,7 +7491,7 @@ function renderPreview(report, options = {}) {
|
|
|
6955
7491
|
}
|
|
6956
7492
|
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
7493
|
const rows = extractions.map((extraction, index) => {
|
|
6958
|
-
const path =
|
|
7494
|
+
const path = pathOf2(extraction.http.finalUrl);
|
|
6959
7495
|
return {
|
|
6960
7496
|
index,
|
|
6961
7497
|
locale: splitRoute(path).locale,
|
|
@@ -6967,7 +7503,7 @@ function renderPreview(report, options = {}) {
|
|
|
6967
7503
|
});
|
|
6968
7504
|
const byRoute = /* @__PURE__ */ new Map();
|
|
6969
7505
|
extractions.forEach((extraction, index) => {
|
|
6970
|
-
const route = splitRoute(
|
|
7506
|
+
const route = splitRoute(pathOf2(extraction.http.finalUrl)).route;
|
|
6971
7507
|
byRoute.set(route, [...byRoute.get(route) ?? [], rows[index]]);
|
|
6972
7508
|
});
|
|
6973
7509
|
const body = extractions.length === 0 ? `<section class="page"><p class="none">${empty}</p></section>` : extractions.map(
|
|
@@ -6975,12 +7511,17 @@ function renderPreview(report, options = {}) {
|
|
|
6975
7511
|
extraction,
|
|
6976
7512
|
issuesByPage.get(extraction.http.finalUrl) ?? [],
|
|
6977
7513
|
index,
|
|
6978
|
-
byRoute.get(splitRoute(
|
|
7514
|
+
byRoute.get(splitRoute(pathOf2(extraction.http.finalUrl)).route) ?? []
|
|
6979
7515
|
)
|
|
6980
7516
|
).join("\n");
|
|
6981
|
-
const nav = extractions.length > 1 ? `<
|
|
6982
|
-
|
|
6983
|
-
|
|
7517
|
+
const nav = extractions.length > 1 ? `<nav class="nav">
|
|
7518
|
+
<p class="navnote">${extractions.length} pages, by path. A folder folds; the \u2197 beside one opens the page at that path itself.</p>
|
|
7519
|
+
${routeList(
|
|
7520
|
+
buildRouteTree(extractions.map((extraction) => pathOf2(extraction.http.finalUrl))),
|
|
7521
|
+
extractions.length <= TREE_OPEN_MAX,
|
|
7522
|
+
"tree"
|
|
7523
|
+
)}
|
|
7524
|
+
</nav>` : "";
|
|
6984
7525
|
return `<!doctype html>
|
|
6985
7526
|
<html lang="en">
|
|
6986
7527
|
<head>
|
|
@@ -7294,4 +7835,4 @@ export {
|
|
|
7294
7835
|
summarize,
|
|
7295
7836
|
Logger
|
|
7296
7837
|
};
|
|
7297
|
-
//# sourceMappingURL=chunk-
|
|
7838
|
+
//# sourceMappingURL=chunk-YQPL67HX.js.map
|