@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.
@@ -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 resolved = safeResolve(l.href, base);
237
- meta.canonical = sourced(resolved, { kind: "link", rel: "canonical" }, l.href);
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 pathOf2 = (value) => {
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 pathOf2(href) === pathOf2(src);
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
- if (metaRobotsTokens(page).has("noindex")) return true;
3505
- const header = page.fetch.headers["x-robots-tag"] ?? "";
3506
- return header.split(",").map((token) => token.trim().toLowerCase()).includes("noindex");
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
- const canonical = page?.links.canonical;
3578
- if (!page || !canonical) continue;
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"} point elsewhere: ${sample(wrong)}. The sitemap is a list of what to index, so it should name the URL the site itself prefers.`,
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
- appliesTo: (site) => site.discovery?.diagnostics.found === true && site.discovery.urls.length > 0,
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 = new Set(
3604
- site.discovery.urls.map((entry) => sameUrlKey(entry.loc)).filter(Boolean)
3605
- );
3606
- const orphans = site.pages.filter((page) => !saysNoindex(page)).map((page) => ({ page, key: sameUrlKey(page.fetch.finalUrl) })).filter(({ key }) => key !== void 0 && !listed.has(key)).map(({ page }) => page.fetch.finalUrl);
3607
- if (orphans.length === 0) return [];
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: `${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)}. A consumer that reads the sitemap rather than following links will never see ${orphans.length === 1 ? "it" : "them"}.`,
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((resolve2) => setTimeout(resolve2, ms));
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
- appliesTo: (site) => site.localeAxis.multilingual && (site.discovery?.urls.length ?? 0) > 0,
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((resolve2) => {
5847
+ return new Promise((resolve3) => {
5404
5848
  if (items.length === 0) {
5405
- resolve2();
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
- resolve2();
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 sameOrigin2 = (url) => {
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 || sameOrigin2(url));
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
- return { kept, dropped: pages.length - kept.length };
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, dropped: duplicatePages } = dropCanonicalDuplicates(documents);
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
- crawled: new Map(
5837
- crawlResult.pages.map((page) => [page.fetch.finalUrl, page.fetch.status])
5838
- ),
5839
- checked: new Map(Object.values(linkReport.checks).map((check) => [check.url, check])),
5840
- maxProbes: effectiveMaxPages,
5841
- signal: options.signal,
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 pathOf(value) {
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 resolve(...facts) {
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: 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),
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{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)}
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 &lt;url&gt;</code>, which does.` : `The crawl reached no HTML page it could read, so there is nothing to draw. Every page was unreachable, not HTML, or a declared duplicate of another.`;
6957
7493
  const rows = extractions.map((extraction, index) => {
6958
- const path = pathOf(extraction.http.finalUrl);
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(pathOf(extraction.http.finalUrl)).route;
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(pathOf(extraction.http.finalUrl)).route) ?? []
7514
+ byRoute.get(splitRoute(pathOf2(extraction.http.finalUrl)).route) ?? []
6979
7515
  )
6980
7516
  ).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>` : "";
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-QKXUKHXB.js.map
7838
+ //# sourceMappingURL=chunk-YQPL67HX.js.map