/**
* Link-crawl surface discovery, for apps whose surfaces aren't filesystem routes.
*
* {@link discoverNextRoutes} reads the filesystem, so it sees one route per
* `app/.../page.*` — perfect for multi-page apps, blind to a single-route SPA that
* expresses every view as a query param (`/?tab=overview`) or client-side push.
* Those surfaces only exist in the *rendered* DOM, as the nav's links. This module
* turns that rendered link set into a surface list: navigate the app's root, read
* its ``s, and capture each — no hand-maintained `surfaces` array to drift
* out of sync with the nav (the same drift the coverage guard exists to catch,
* removed at the source).
*
* The DOM read happens at run time inside a Playwright test (a browser is needed to
* see hydrated links), so this file holds only the PURE part — turning a list of
* raw href strings into deduped, keyed, navigable surfaces — which is unit-testable
* with no browser. {@link defineCrawlCapture} in `runner.ts` does the navigation and
* feeds the hrefs here.
*/
/** A discovered surface: a filename-safe key and the same-origin path to navigate. */
export type CrawlLink = {
/** Capture file-name prefix, derived from the URL (see {@link defaultLinkKey}). */
key: string;
/** Root-relative path+query to navigate (`/?tab=overview`, `/about`). */
url: string;
};
/**
* Keep only links whose resolved URL matches: a substring tested against the full
* href, a RegExp tested against it, or a predicate over the parsed URL. Omit to keep
* every same-origin link.
*/
export type LinkMatch = string | RegExp | ((url: URL) => boolean);
export type SelectLinksOptions = {
/** Absolute URL of the crawled page. Relative hrefs resolve against it and only
* same-origin links are kept (external nav, mailto:, tel:, javascript: dropped). */
base: string;
/** Narrow the kept links. Default: every same-origin link. */
match?: LinkMatch;
/** Derive the surface key from a link URL. Default: {@link defaultLinkKey}. */
key?: (url: URL) => string;
/** Also capture the crawled page itself as the first surface, so `from` is always
* covered — even if the nav doesn't link back to it, or it's a single-page app with
* no links at all. Default false. Used for an unfiltered "capture everything" crawl. */
includeSelf?: boolean;
};
/**
* Filename-safe, readable key from a link URL. Joins the path segments and the
* query-param *values* (the discriminator for a tab SPA — `/?tab=overview` →
* `overview`), so the common single-route-with-`?tab=` case reads cleanly while a
* multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
* dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
* project needs a different scheme.
*
* Params are sorted by name before their values are joined, so the SAME logical
* route keys identically regardless of the order the nav happened to render its
* query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the
* key flaps with render order and the coverage guard reports phantom
* nav-regressions / unowned routes for a route that never changed.
*/
export declare function defaultLinkKey(url: URL): string;
/**
* Dedup identity for a navigable path+query. Two forms of the same route must share
* one identity, or a static multi-page site (whose nav links the `.html` files) gets
* captured twice as byte-near-identical maps, doubling the work and duplicating every
* finding in the diff:
*
* - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same
* route), so it's stripped — but never from the root `/` itself, nor from the query.
* - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and
* `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the
* literal `index.html` filename normalizes — a real `about.html` is left untouched
* and stays a distinct surface from `about`.
*
* The navigable url the caller returns keeps its original form; only the SET
* membership test is normalized, so the first-seen href still wins.
*/
export declare function dedupIdentity(pathAndSearch: string): string;
/**
* Turn a page's raw `` values into a deduped, keyed surface list.
*
* Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
* same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
* survivors are deduped by path+query (trailing slash normalized — `/about` and
* `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
* the capture order is the nav's order — stable across runs.
*
* Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
* collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
* would otherwise both write `@.json.gz` and the second would silently
* overwrite the first — a captured surface vanishing without a trace. Instead the
* second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
* survive as distinct maps. Trailing-slash duplicates never reach here — they're
* already deduped to one surface above — so this only fires on real collisions.
*/
export declare function selectCrawlLinks(hrefs: Iterable, opts: SelectLinksOptions): CrawlLink[];
/**
* The reconciliation of a rendered nav (the crawl's discovered link keys) against a
* declared `expected` universe, both directions. Where the spec guard treats the
* hand-listed `surfaces` as what's captured, here the crawl's DISCOVERED links are —
* the nav is the route universe for a link-crawled SPA, so it is the source of truth.
*
* - `missing`: an `expected` key with no rendered link and no `exclude` entry — a
* nav-regression (a route the app promised is no longer linked).
* - `unexpected`: a rendered link with no `expected` entry and no `exclude` entry — a
* new route/view with no owner in the registry.
* - `staleExclusions`: an `exclude` key absent from BOTH `expected` and the rendered
* set — a rotted opt-out.
*
* Unlike {@link CoverageGaps} (which permits captured-not-expected so a spec can
* tighten its registry over time), the crawl asserts BOTH directions strictly: the
* rendered link set is complete by construction, so an unowned link is a real gap.
* Pure and browser-free so it's unit-testable; {@link import('./runner.js')} wraps it
* in the crawl capture test, where the link set is finally known.
*/
export type CrawlCoverageGaps = {
/** Expected keys with no rendered link and no `exclude` — a nav regression. */
missing: string[];
/** Rendered link keys absent from `expected` and `exclude` — a route with no owner. */
unexpected: string[];
/** `exclude` keys in neither `expected` nor the rendered set — a rotted opt-out. */
staleExclusions: string[];
};
export declare function crawlCoverageGaps(discoveredKeys: Iterable, expected: Iterable, exclude?: Record): CrawlCoverageGaps;
/**
* Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
* render the failure message, or `null` when the nav reconciles. `from` names the crawl
* root in the message. Kept pure and out of the capture test so the wording is
* unit-testable and {@link defineCrawlCapture} just throws what this returns.
*/
export declare function crawlCoverageError(from: string, discoveredKeys: Iterable, expected: Iterable, exclude?: Record): string | null;