@goflag/cli 0.2.14 → 0.2.15

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 CHANGED
@@ -37,7 +37,7 @@ here and carries no code.
37
37
 
38
38
  ## What it does
39
39
 
40
- - **Broken links** — scrapes every crawled page, dedupes targets globally (a footer link on 500 pages is probed once), and checks each with `HEAD`→`GET` fallback, redirect-chain/loop detection, soft-404 and anti-bot (403/429) triage. Broken links are mapped back to the pages that reference them.
40
+ - **Broken links** — reads the links of every crawled page from the copy the crawl kept, dedupes targets globally (a footer link on 500 pages is probed once, and a link to a page the crawl fetched without a redirect is judged from that answer rather than asked again), and checks each with `HEAD`→`GET` fallback, redirect-chain/loop detection, soft-404 and anti-bot (403/429) triage. Broken links are mapped back to the pages that reference them.
41
41
  - **Missing translation pages** — builds a `route × locale` matrix and flags every route that exists in one locale but is missing in another, plus hreflang reciprocity gaps (`A → B` with no `B → A`), missing `x-default`, and locale tags naming a language, script or region that does not exist. Rows are keyed by pathname, unless the site declares its translation clusters — either with `xhtml:link` in the sitemap, or with reciprocal `hreflang` between two crawled pages' `<head>`. Then those declarations decide which URLs are one page, whatever their slugs, and the run reports `diagnostics.declaredClusters`. The sitemap source survives `--coverage structural`, where the two locales of a slug-translating family are rarely both sampled; the `<head>` source needs both pages in hand and is what fixes the common site that declares properly there and nothing in its sitemap.
42
42
  - **robots.txt policy** — the file is parsed to RFC 9309, not scanned: every group, every rule, every line it could not read, each with its number. goflag flags a site that forbids crawling while its pages ask to be indexed — site-wide, and now per path, which is the quiet version nothing was watching. It also reports the file that errors (a 5xx there is read as a site-wide ban), one over the 500 KiB a parser must honour, a typo that silently deletes the rule you meant to write, a redirect handing your crawl policy to another origin, and a `Sitemap:` that is not a full URL.
43
43
  - **SEO metadata** — lints each page's `<head>` for the handful of mistakes that actually hurt in search and social and are invisible in a browser: missing/oversized `<title>` and description, missing/relative canonical, missing `og:title`/`og:description`/`og:image`, missing viewport, and contradictory `robots`/`googlebot`/`X-Robots-Tag` directives.
@@ -558,7 +558,7 @@ Three rules, whichever you use:
558
558
  stages: [build, deploy, audit]
559
559
 
560
560
  variables:
561
- GOFLAG_VERSION: "0.2.14"
561
+ GOFLAG_VERSION: "0.2.15"
562
562
 
563
563
  # Before the merge: build the branch, let goflag boot it, audit that.
564
564
  seo:mr:
@@ -610,7 +610,7 @@ jobs:
610
610
  - run: corepack enable && pnpm install --frozen-lockfile
611
611
  - run: pnpm build
612
612
  - run: |
613
- npx --yes @goflag/cli@0.2.14 http://localhost:3000 \
613
+ npx --yes @goflag/cli@0.2.15 http://localhost:3000 \
614
614
  --start "pnpm start" --static --no-external \
615
615
  --baseline .goflag/baseline.json --regressions-only --max-debt 41 \
616
616
  --report goflag-report.json
@@ -25,6 +25,25 @@ function fingerprint(category, ...parts) {
25
25
  return `${category}-${digest}`;
26
26
  }
27
27
 
28
+ // src/lib/core/canonicalise.ts
29
+ function canonicaliseUrl(href, base, options = {}) {
30
+ let url;
31
+ try {
32
+ url = new URL(href, base);
33
+ } catch {
34
+ return null;
35
+ }
36
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
37
+ url.hash = "";
38
+ if (!options.preserveTrailingSlash && url.pathname.length > 1 && url.pathname.endsWith("/")) {
39
+ url.pathname = url.pathname.slice(0, -1);
40
+ }
41
+ return url.toString();
42
+ }
43
+
44
+ // src/lib/core/inspect.ts
45
+ import * as cheerio3 from "cheerio";
46
+
28
47
  // src/lib/core/extract/static.ts
29
48
  import * as cheerio from "cheerio";
30
49
 
@@ -105,7 +124,9 @@ function decodeHtmlEntities(s) {
105
124
 
106
125
  // src/lib/core/extract/static.ts
107
126
  function extractStatic(html, options) {
108
- const $ = cheerio.load(html);
127
+ return extractStaticFrom(cheerio.load(html), options);
128
+ }
129
+ function extractStaticFrom($, options) {
109
130
  const base = resolveBase($, options.baseUrl);
110
131
  const raw = extractRaw($);
111
132
  const meta = parseGenericMeta(raw, base);
@@ -952,12 +973,98 @@ function countEntries(body) {
952
973
  return url + sm;
953
974
  }
954
975
 
976
+ // src/lib/core/links/extract.ts
977
+ import * as cheerio2 from "cheerio";
978
+ var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
979
+ var ASSET_SPECS = [
980
+ { selector: "img[src]", attr: "src", source: "img" },
981
+ { selector: "script[src]", attr: "src", source: "script" },
982
+ { selector: "link[href]", attr: "href", source: "link" },
983
+ { selector: "iframe[src]", attr: "src", source: "iframe" }
984
+ ];
985
+ function extractLinks(html, options) {
986
+ return extractLinksFrom(cheerio2.load(html), options);
987
+ }
988
+ function extractLinksFrom($, options) {
989
+ const base = resolveBase2($, options.baseUrl);
990
+ const origin = safeOrigin(options.baseUrl);
991
+ const specs = options.includeAssets ? [ANCHOR_SPEC, ...ASSET_SPECS] : [ANCHOR_SPEC];
992
+ const out = [];
993
+ const seen = /* @__PURE__ */ new Set();
994
+ for (const spec of specs) {
995
+ $(spec.selector).each((_, el) => {
996
+ const raw = $(el).attr(spec.attr);
997
+ if (raw === void 0) return;
998
+ const rawHref = raw.trim();
999
+ if (!rawHref) return;
1000
+ const ref = buildRef($, el, rawHref, spec, base, origin);
1001
+ if (!ref) return;
1002
+ const dedupeKey = `${ref.source}|${ref.url}`;
1003
+ if (seen.has(dedupeKey)) return;
1004
+ seen.add(dedupeKey);
1005
+ out.push(ref);
1006
+ });
1007
+ }
1008
+ return out;
1009
+ }
1010
+ function buildRef($, el, rawHref, spec, base, origin) {
1011
+ const fragment = extractFragment(rawHref);
1012
+ const canonical = canonicaliseUrl(rawHref, base, { preserveTrailingSlash: true });
1013
+ const url = canonical ?? rawHref;
1014
+ const kind = canonical ? classify(canonical, origin) : "external";
1015
+ const ref = {
1016
+ rawHref,
1017
+ url,
1018
+ kind,
1019
+ source: spec.source,
1020
+ rel: spec.source === "a" || spec.source === "link" ? parseRel($(el).attr("rel")) : []
1021
+ };
1022
+ if (spec.source === "a") {
1023
+ const text = $(el).text().trim();
1024
+ if (text) ref.anchorText = text;
1025
+ }
1026
+ if (fragment) ref.fragment = fragment;
1027
+ return ref;
1028
+ }
1029
+ function extractFragment(rawHref) {
1030
+ const hash = rawHref.indexOf("#");
1031
+ if (hash === -1) return void 0;
1032
+ const frag = rawHref.slice(hash);
1033
+ return frag.length > 1 ? frag : void 0;
1034
+ }
1035
+ function parseRel(rel) {
1036
+ if (!rel) return [];
1037
+ return rel.split(/\s+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
1038
+ }
1039
+ function classify(url, origin) {
1040
+ if (!origin) return "external";
1041
+ return safeOrigin(url) === origin ? "internal" : "external";
1042
+ }
1043
+ function safeOrigin(url) {
1044
+ try {
1045
+ return new URL(url).origin;
1046
+ } catch {
1047
+ return null;
1048
+ }
1049
+ }
1050
+ function resolveBase2($, pageUrl) {
1051
+ const declared = $("base[href]").first().attr("href")?.trim();
1052
+ if (declared) {
1053
+ try {
1054
+ return new URL(declared, pageUrl).toString();
1055
+ } catch {
1056
+ }
1057
+ }
1058
+ return pageUrl;
1059
+ }
1060
+
955
1061
  // src/lib/core/inspect.ts
956
1062
  async function inspect(url, options = {}) {
957
1063
  const {
958
1064
  probes: enableProbes = true,
959
1065
  mode = "auto",
960
1066
  headless: headlessOpts,
1067
+ linkRefs: wantLinkRefs = false,
961
1068
  ...fetchOptions
962
1069
  } = options;
963
1070
  const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -971,7 +1078,8 @@ async function inspect(url, options = {}) {
971
1078
  });
972
1079
  }
973
1080
  const { meta: staticMeta, body: staticBody } = await fetchStatic(url, fetchOptions);
974
- const staticParsed = extractStatic(staticBody, { baseUrl: staticMeta.finalUrl });
1081
+ const { staticParsed, linkRefs } = parseStatic(staticBody, staticMeta.finalUrl, wantLinkRefs);
1082
+ const withLinkRefs = linkRefs ? { linkRefs } : {};
975
1083
  if (mode === "static") {
976
1084
  const probes2 = await runProbes({
977
1085
  enabled: enableProbes,
@@ -985,7 +1093,8 @@ async function inspect(url, options = {}) {
985
1093
  fetch: staticMeta,
986
1094
  extractor: { mode: "static", escalated: false },
987
1095
  html: { static: staticBody },
988
- probes: probes2
1096
+ probes: probes2,
1097
+ ...withLinkRefs
989
1098
  };
990
1099
  }
991
1100
  const heuristic = looksClientRendered(staticParsed);
@@ -1002,7 +1111,8 @@ async function inspect(url, options = {}) {
1002
1111
  fetch: staticMeta,
1003
1112
  extractor: { mode: "static", escalated: false },
1004
1113
  html: { static: staticBody },
1005
- probes: probes2
1114
+ probes: probes2,
1115
+ ...withLinkRefs
1006
1116
  };
1007
1117
  }
1008
1118
  let renderedBody;
@@ -1034,7 +1144,8 @@ async function inspect(url, options = {}) {
1034
1144
  escalationBlocked: err.message
1035
1145
  },
1036
1146
  html: { static: staticBody },
1037
- probes: probes2
1147
+ probes: probes2,
1148
+ ...withLinkRefs
1038
1149
  };
1039
1150
  }
1040
1151
  const renderedParsed = extractStatic(renderedBody, { baseUrl: renderedFetch.finalUrl });
@@ -1056,9 +1167,16 @@ async function inspect(url, options = {}) {
1056
1167
  },
1057
1168
  html: { static: staticBody, rendered: renderedBody },
1058
1169
  hydration,
1059
- probes
1170
+ probes,
1171
+ ...withLinkRefs
1060
1172
  };
1061
1173
  }
1174
+ function parseStatic(html, baseUrl, wantLinkRefs) {
1175
+ const $ = cheerio3.load(html);
1176
+ const staticParsed = extractStaticFrom($, { baseUrl });
1177
+ if (!wantLinkRefs) return { staticParsed };
1178
+ return { staticParsed, linkRefs: extractLinksFrom($, { baseUrl }) };
1179
+ }
1062
1180
  async function inspectHeadlessOnly(args) {
1063
1181
  const { url, fetchedAt, enableProbes, fetchOptions, headlessOpts } = args;
1064
1182
  const result = await extractHeadless(url, {
@@ -1209,60 +1327,74 @@ async function crawl(options) {
1209
1327
  const recovered = [];
1210
1328
  let truncated = false;
1211
1329
  const seenFinal = /* @__PURE__ */ new Set();
1212
- while (queue.length > 0) {
1330
+ const inspectItem = async (item) => {
1331
+ try {
1332
+ const page = await inspect(item.url, options.inspectOptions);
1333
+ return { kind: "ok", item, page };
1334
+ } catch (err) {
1335
+ return { kind: "err", item, error: err instanceof Error ? err.message : String(err) };
1336
+ }
1337
+ };
1338
+ const pending = [];
1339
+ const maxPending = concurrency * 4;
1340
+ let active = 0;
1341
+ let requestDone = () => void 0;
1342
+ const start = (item) => {
1343
+ active += 1;
1344
+ pending.push(
1345
+ inspectItem(item).finally(() => {
1346
+ active -= 1;
1347
+ requestDone();
1348
+ })
1349
+ );
1350
+ };
1351
+ while (queue.length > 0 || pending.length > 0) {
1213
1352
  if (options.inspectOptions?.signal?.aborted) break;
1214
- const wave = queue.splice(0, concurrency);
1215
- const inspections = wave.map(async (item) => {
1216
- try {
1217
- const page = await inspect(item.url, options.inspectOptions);
1218
- return { kind: "ok", item, page };
1219
- } catch (err) {
1220
- return {
1221
- kind: "err",
1222
- item,
1223
- error: err instanceof Error ? err.message : String(err)
1224
- };
1225
- }
1353
+ while (active < concurrency && pending.length < maxPending && queue.length > 0) {
1354
+ start(queue.shift());
1355
+ }
1356
+ const oldest = pending[0];
1357
+ const finished = new Promise((resolve3) => {
1358
+ requestDone = resolve3;
1226
1359
  });
1227
- const results = await Promise.all(inspections);
1228
- for (const result of results) {
1229
- if (result.kind === "err") {
1230
- const attempt = (result.item.attempt ?? 0) + 1;
1231
- if (attempt < MAX_ATTEMPTS) {
1232
- queue.push({ ...result.item, attempt });
1233
- continue;
1234
- }
1235
- errors.push({ url: result.item.url, message: result.error });
1360
+ const oldestReady = await Promise.race([oldest.then(() => true), finished.then(() => false)]);
1361
+ if (!oldestReady) continue;
1362
+ const result = await pending.shift();
1363
+ if (result.kind === "err") {
1364
+ const attempt = (result.item.attempt ?? 0) + 1;
1365
+ if (attempt < MAX_ATTEMPTS) {
1366
+ queue.push({ ...result.item, attempt });
1236
1367
  continue;
1237
1368
  }
1238
- if (result.item.attempt) recovered.push(result.item.url);
1239
- const finalUrl = canonicaliseUrl(result.page.fetch.finalUrl) ?? result.page.fetch.finalUrl;
1240
- if (seenFinal.has(finalUrl)) continue;
1241
- seenFinal.add(finalUrl);
1242
- pages.push(result.page);
1243
- if (options.onPage) {
1244
- options.onPage(result.page, { visited: pages.length, queued: queue.length });
1245
- }
1246
- if (pages.length >= maxPages) {
1247
- truncated = true;
1248
- break;
1249
- }
1250
- if (result.item.depth >= depthLimit) continue;
1251
- const candidates = extractCandidateLinks(result.page);
1252
- for (const candidate of candidates) {
1253
- const normalised = canonicaliseUrl(candidate.href, result.page.fetch.finalUrl);
1254
- if (!normalised) continue;
1255
- if (visitedSet.has(normalised)) continue;
1256
- const parsed = new URL(normalised);
1257
- if (parsed.origin !== origin) continue;
1258
- const passesIncludeExclude = filterAllows(parsed.pathname, include, exclude);
1259
- const isHreflang = candidate.source === "hreflang";
1260
- if (!passesIncludeExclude && !(followHreflang && isHreflang)) continue;
1261
- visitedSet.add(normalised);
1262
- queue.push({ url: normalised, depth: result.item.depth + 1 });
1263
- }
1369
+ errors.push({ url: result.item.url, message: result.error });
1370
+ continue;
1371
+ }
1372
+ if (result.item.attempt) recovered.push(result.item.url);
1373
+ const finalUrl = canonicaliseUrl(result.page.fetch.finalUrl) ?? result.page.fetch.finalUrl;
1374
+ if (seenFinal.has(finalUrl)) continue;
1375
+ seenFinal.add(finalUrl);
1376
+ pages.push(result.page);
1377
+ if (options.onPage) {
1378
+ options.onPage(result.page, { visited: pages.length, queued: queue.length });
1379
+ }
1380
+ if (pages.length >= maxPages) {
1381
+ truncated = true;
1382
+ break;
1383
+ }
1384
+ if (result.item.depth >= depthLimit) continue;
1385
+ const candidates = extractCandidateLinks(result.page);
1386
+ for (const candidate of candidates) {
1387
+ const normalised = canonicaliseUrl(candidate.href, result.page.fetch.finalUrl);
1388
+ if (!normalised) continue;
1389
+ if (visitedSet.has(normalised)) continue;
1390
+ const parsed = new URL(normalised);
1391
+ if (parsed.origin !== origin) continue;
1392
+ const passesIncludeExclude = filterAllows(parsed.pathname, include, exclude);
1393
+ const isHreflang = candidate.source === "hreflang";
1394
+ if (!passesIncludeExclude && !(followHreflang && isHreflang)) continue;
1395
+ visitedSet.add(normalised);
1396
+ queue.push({ url: normalised, depth: result.item.depth + 1 });
1264
1397
  }
1265
- if (truncated) break;
1266
1398
  }
1267
1399
  return {
1268
1400
  pages,
@@ -1277,20 +1409,6 @@ function filterAllows(pathname, include, exclude) {
1277
1409
  if (include.length === 0) return true;
1278
1410
  return matchesAny(pathname, include);
1279
1411
  }
1280
- function canonicaliseUrl(href, base, options = {}) {
1281
- let url;
1282
- try {
1283
- url = new URL(href, base);
1284
- } catch {
1285
- return null;
1286
- }
1287
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
1288
- url.hash = "";
1289
- if (!options.preserveTrailingSlash && url.pathname.length > 1 && url.pathname.endsWith("/")) {
1290
- url.pathname = url.pathname.slice(0, -1);
1291
- }
1292
- return url.toString();
1293
- }
1294
1412
 
1295
1413
  // src/lib/rules/evaluate.ts
1296
1414
  function bandFor(value, bands) {
@@ -4183,7 +4301,7 @@ function suggestedLocales(axis) {
4183
4301
  import { gunzipSync } from "zlib";
4184
4302
 
4185
4303
  // src/lib/core/sitemap/parse.ts
4186
- import * as cheerio2 from "cheerio";
4304
+ import * as cheerio4 from "cheerio";
4187
4305
  function alternatesOf(node) {
4188
4306
  const out = [];
4189
4307
  const seen = /* @__PURE__ */ new Set();
@@ -4211,7 +4329,7 @@ function parseSitemap(body) {
4211
4329
  }
4212
4330
  let $;
4213
4331
  try {
4214
- $ = cheerio2.load(trimmed, { xmlMode: true });
4332
+ $ = cheerio4.load(trimmed, { xmlMode: true });
4215
4333
  } catch {
4216
4334
  return { kind: "unknown", wellFormed: false, urls: [], sitemaps: [] };
4217
4335
  }
@@ -4608,6 +4726,10 @@ function normaliseContentType(raw) {
4608
4726
  const semi = raw.indexOf(";");
4609
4727
  return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase() || void 0;
4610
4728
  }
4729
+ function capText(text, maxBytes) {
4730
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
4731
+ return Buffer.from(text.slice(0, maxBytes), "utf8").subarray(0, maxBytes).toString("utf8");
4732
+ }
4611
4733
  async function readCappedText(res, maxBytes) {
4612
4734
  const reader = res.body?.getReader();
4613
4735
  if (!reader) {
@@ -4740,6 +4862,34 @@ async function checkLink(url, options = {}) {
4740
4862
  durationMs: Date.now() - started
4741
4863
  };
4742
4864
  }
4865
+ function checkFromAnswer(url, answer, options = {}) {
4866
+ if (answer.status < 200 || answer.status >= 300) return void 0;
4867
+ const outcome = {
4868
+ status: answer.status,
4869
+ finalUrl: url,
4870
+ redirectChain: [],
4871
+ loop: false,
4872
+ body: capText(answer.body, options.softBodyBytes ?? DEFAULTS2.softBodyBytes),
4873
+ method: "GET"
4874
+ };
4875
+ const softNotFound = isSoftNotFound(outcome);
4876
+ return {
4877
+ url,
4878
+ finalUrl: url,
4879
+ status: outcome.status,
4880
+ verdict: classifyLink({
4881
+ status: outcome.status,
4882
+ redirected: false,
4883
+ softNotFound,
4884
+ loop: false
4885
+ }),
4886
+ method: "GET",
4887
+ redirectChain: [],
4888
+ reason: describeReason(outcome, softNotFound),
4889
+ checkedAt: answer.checkedAt,
4890
+ durationMs: answer.durationMs
4891
+ };
4892
+ }
4743
4893
  async function probeWithRetry(url, options) {
4744
4894
  const retries = options.retries ?? DEFAULTS2.retries;
4745
4895
  const sleep = options.sleep ?? defaultSleep;
@@ -5608,89 +5758,6 @@ function collectSiteAdvisories(site, rules = SITE_PROSE_RULES) {
5608
5758
  return out;
5609
5759
  }
5610
5760
 
5611
- // src/lib/core/links/extract.ts
5612
- import * as cheerio3 from "cheerio";
5613
- var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
5614
- var ASSET_SPECS = [
5615
- { selector: "img[src]", attr: "src", source: "img" },
5616
- { selector: "script[src]", attr: "src", source: "script" },
5617
- { selector: "link[href]", attr: "href", source: "link" },
5618
- { selector: "iframe[src]", attr: "src", source: "iframe" }
5619
- ];
5620
- function extractLinks(html, options) {
5621
- const $ = cheerio3.load(html);
5622
- const base = resolveBase2($, options.baseUrl);
5623
- const origin = safeOrigin(options.baseUrl);
5624
- const specs = options.includeAssets ? [ANCHOR_SPEC, ...ASSET_SPECS] : [ANCHOR_SPEC];
5625
- const out = [];
5626
- const seen = /* @__PURE__ */ new Set();
5627
- for (const spec of specs) {
5628
- $(spec.selector).each((_, el) => {
5629
- const raw = $(el).attr(spec.attr);
5630
- if (raw === void 0) return;
5631
- const rawHref = raw.trim();
5632
- if (!rawHref) return;
5633
- const ref = buildRef($, el, rawHref, spec, base, origin);
5634
- if (!ref) return;
5635
- const dedupeKey = `${ref.source}|${ref.url}`;
5636
- if (seen.has(dedupeKey)) return;
5637
- seen.add(dedupeKey);
5638
- out.push(ref);
5639
- });
5640
- }
5641
- return out;
5642
- }
5643
- function buildRef($, el, rawHref, spec, base, origin) {
5644
- const fragment = extractFragment(rawHref);
5645
- const canonical = canonicaliseUrl(rawHref, base, { preserveTrailingSlash: true });
5646
- const url = canonical ?? rawHref;
5647
- const kind = canonical ? classify(canonical, origin) : "external";
5648
- const ref = {
5649
- rawHref,
5650
- url,
5651
- kind,
5652
- source: spec.source,
5653
- rel: spec.source === "a" || spec.source === "link" ? parseRel($(el).attr("rel")) : []
5654
- };
5655
- if (spec.source === "a") {
5656
- const text = $(el).text().trim();
5657
- if (text) ref.anchorText = text;
5658
- }
5659
- if (fragment) ref.fragment = fragment;
5660
- return ref;
5661
- }
5662
- function extractFragment(rawHref) {
5663
- const hash = rawHref.indexOf("#");
5664
- if (hash === -1) return void 0;
5665
- const frag = rawHref.slice(hash);
5666
- return frag.length > 1 ? frag : void 0;
5667
- }
5668
- function parseRel(rel) {
5669
- if (!rel) return [];
5670
- return rel.split(/\s+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
5671
- }
5672
- function classify(url, origin) {
5673
- if (!origin) return "external";
5674
- return safeOrigin(url) === origin ? "internal" : "external";
5675
- }
5676
- function safeOrigin(url) {
5677
- try {
5678
- return new URL(url).origin;
5679
- } catch {
5680
- return null;
5681
- }
5682
- }
5683
- function resolveBase2($, pageUrl) {
5684
- const declared = $("base[href]").first().attr("href")?.trim();
5685
- if (declared) {
5686
- try {
5687
- return new URL(declared, pageUrl).toString();
5688
- } catch {
5689
- }
5690
- }
5691
- return pageUrl;
5692
- }
5693
-
5694
5761
  // src/lib/core/links/types.ts
5695
5762
  function emptyVerdictSummary() {
5696
5763
  return { ok: 0, redirect: 0, broken: 0, blocked: 0, warning: 0, skipped: 0 };
@@ -5722,8 +5789,16 @@ async function runLinkAudit(discovery, options = {}) {
5722
5789
  let pagesScanned = 0;
5723
5790
  let pagesFailed = 0;
5724
5791
  let scanDone = 0;
5792
+ const maxBytes = options.maxScanBytes ?? DEFAULTS3.maxScanBytes;
5793
+ const handedPages = [];
5794
+ const toFetch = [];
5795
+ for (const pageUrl of pageUrls) {
5796
+ const handed = options.pages?.get(pageUrl);
5797
+ if (handed) handedPages.push([pageUrl, handed]);
5798
+ else toFetch.push(pageUrl);
5799
+ }
5725
5800
  await mapWithHostCaps(
5726
- pageUrls,
5801
+ toFetch,
5727
5802
  {
5728
5803
  concurrency: options.scanConcurrency ?? DEFAULTS3.scanConcurrency,
5729
5804
  perHost: Infinity,
@@ -5735,7 +5810,7 @@ async function runLinkAudit(discovery, options = {}) {
5735
5810
  timeoutMs: options.timeoutMs ?? DEFAULTS3.timeoutMs,
5736
5811
  allowInsecureTls: options.allowInsecureTls,
5737
5812
  userAgent: options.userAgent,
5738
- maxBytes: options.maxScanBytes ?? DEFAULTS3.maxScanBytes,
5813
+ maxBytes,
5739
5814
  signal: options.signal
5740
5815
  });
5741
5816
  if (res.status === 0 || res.status >= 400 || res.body === void 0) {
@@ -5753,6 +5828,17 @@ async function runLinkAudit(discovery, options = {}) {
5753
5828
  },
5754
5829
  () => "scan"
5755
5830
  );
5831
+ for (const [pageUrl, handed] of handedPages) {
5832
+ if (options.signal?.aborted) break;
5833
+ const refs = handed.links && !options.includeAssets && Buffer.byteLength(handed.html) <= maxBytes ? handed.links : extractLinks(capText(handed.html, maxBytes), {
5834
+ baseUrl: pageUrl,
5835
+ includeAssets: options.includeAssets
5836
+ });
5837
+ for (const ref of refs) occurrences.push({ pageUrl, ref });
5838
+ pagesScanned += 1;
5839
+ scanDone += 1;
5840
+ options.onProgress?.({ phase: "scan", done: scanDone, total: pageUrls.length });
5841
+ }
5756
5842
  const uniqueUrls = [];
5757
5843
  const seen = /* @__PURE__ */ new Set();
5758
5844
  for (const occ of occurrences) {
@@ -5769,8 +5855,20 @@ async function runLinkAudit(discovery, options = {}) {
5769
5855
  }
5770
5856
  const checks = {};
5771
5857
  let checkDone = 0;
5858
+ const toProbe = [];
5859
+ for (const url of uniqueUrls) {
5860
+ const answer = options.answers?.get(url);
5861
+ const check = answer ? checkFromAnswer(url, answer) : void 0;
5862
+ if (!check) {
5863
+ toProbe.push(url);
5864
+ continue;
5865
+ }
5866
+ checks[url] = check;
5867
+ checkDone += 1;
5868
+ options.onProgress?.({ phase: "check", done: checkDone, total: uniqueUrls.length });
5869
+ }
5772
5870
  await mapWithHostCaps(
5773
- uniqueUrls,
5871
+ toProbe,
5774
5872
  {
5775
5873
  concurrency: options.checkConcurrency ?? DEFAULTS3.checkConcurrency,
5776
5874
  perHost: options.maxPerHost ?? DEFAULTS3.maxPerHost,
@@ -6136,6 +6234,7 @@ async function runAudit(inputUrl, options = {}) {
6136
6234
  inspectOptions: {
6137
6235
  mode: options.static ? "static" : "auto",
6138
6236
  probes: false,
6237
+ linkRefs: true,
6139
6238
  allowInsecureTls: options.allowInsecureTls,
6140
6239
  timeoutMs: options.timeoutMs,
6141
6240
  signal: options.signal
@@ -6276,6 +6375,16 @@ async function runAudit(inputUrl, options = {}) {
6276
6375
  checkExternal,
6277
6376
  timeoutMs: options.timeoutMs,
6278
6377
  maxPages: effectiveMaxPages,
6378
+ pages: new Map(
6379
+ okPages.filter(isHtmlPage).map((page) => [
6380
+ page.fetch.finalUrl,
6381
+ {
6382
+ html: page.html.static,
6383
+ links: page.extractor.escalated ? void 0 : page.linkRefs
6384
+ }
6385
+ ])
6386
+ ),
6387
+ answers: crawlAnswers(okPages),
6279
6388
  signal: options.signal,
6280
6389
  onProgress: (p) => options.onProgress?.({
6281
6390
  phase: p.phase === "scan" ? "scan" : "links",
@@ -6487,6 +6596,21 @@ function sameOriginAs(url, origin) {
6487
6596
  return false;
6488
6597
  }
6489
6598
  }
6599
+ function crawlAnswers(pages) {
6600
+ const answers = /* @__PURE__ */ new Map();
6601
+ for (const page of pages) {
6602
+ if (!isOkStatus(page.fetch.status) || !isHtmlPage(page)) continue;
6603
+ if (page.extractor.escalated) continue;
6604
+ if (page.fetch.redirectCount > 0 || page.fetch.finalUrl !== page.fetch.requestedUrl) continue;
6605
+ answers.set(page.fetch.requestedUrl, {
6606
+ status: page.fetch.status,
6607
+ body: page.html.static,
6608
+ checkedAt: page.fetchedAt,
6609
+ durationMs: page.fetch.durationMs
6610
+ });
6611
+ }
6612
+ return answers;
6613
+ }
6490
6614
  function syntheticDiscovery(origin, baseUrl, pages, truncated) {
6491
6615
  return {
6492
6616
  origin,
@@ -7835,4 +7959,4 @@ export {
7835
7959
  summarize,
7836
7960
  Logger
7837
7961
  };
7838
- //# sourceMappingURL=chunk-YQPL67HX.js.map
7962
+ //# sourceMappingURL=chunk-KHIN5TNX.js.map