bfsg-scanner 0.1.0
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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +29 -0
- package/dist/cli/run.js +202 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +3 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/load.d.ts +12 -0
- package/dist/config/load.js +47 -0
- package/dist/config/load.js.map +1 -0
- package/dist/config/schema.d.ts +51 -0
- package/dist/config/schema.js +16 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/discovery/crawl.d.ts +21 -0
- package/dist/discovery/crawl.js +79 -0
- package/dist/discovery/crawl.js.map +1 -0
- package/dist/discovery/discover-site.d.ts +26 -0
- package/dist/discovery/discover-site.js +50 -0
- package/dist/discovery/discover-site.js.map +1 -0
- package/dist/discovery/discover.d.ts +25 -0
- package/dist/discovery/discover.js +81 -0
- package/dist/discovery/discover.js.map +1 -0
- package/dist/discovery/index.d.ts +11 -0
- package/dist/discovery/index.js +7 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/links.d.ts +11 -0
- package/dist/discovery/links.js +69 -0
- package/dist/discovery/links.js.map +1 -0
- package/dist/discovery/robots.d.ts +18 -0
- package/dist/discovery/robots.js +91 -0
- package/dist/discovery/robots.js.map +1 -0
- package/dist/discovery/sitemap.d.ts +18 -0
- package/dist/discovery/sitemap.js +36 -0
- package/dist/discovery/sitemap.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/report/build.d.ts +14 -0
- package/dist/report/build.js +100 -0
- package/dist/report/build.js.map +1 -0
- package/dist/report/clauses.d.ts +32 -0
- package/dist/report/clauses.js +57 -0
- package/dist/report/clauses.js.map +1 -0
- package/dist/report/i18n.d.ts +54 -0
- package/dist/report/i18n.js +95 -0
- package/dist/report/i18n.js.map +1 -0
- package/dist/report/index.d.ts +12 -0
- package/dist/report/index.js +8 -0
- package/dist/report/index.js.map +1 -0
- package/dist/report/pdf.d.ts +12 -0
- package/dist/report/pdf.js +25 -0
- package/dist/report/pdf.js.map +1 -0
- package/dist/report/render.d.ts +8 -0
- package/dist/report/render.js +204 -0
- package/dist/report/render.js.map +1 -0
- package/dist/report/types.d.ts +78 -0
- package/dist/report/types.js +2 -0
- package/dist/report/types.js.map +1 -0
- package/dist/report/verdict.d.ts +20 -0
- package/dist/report/verdict.js +28 -0
- package/dist/report/verdict.js.map +1 -0
- package/dist/report/write.d.ts +21 -0
- package/dist/report/write.js +35 -0
- package/dist/report/write.js.map +1 -0
- package/dist/scan/index.d.ts +2 -0
- package/dist/scan/index.js +2 -0
- package/dist/scan/index.js.map +1 -0
- package/dist/scan/scan.d.ts +2 -0
- package/dist/scan/scan.js +82 -0
- package/dist/scan/scan.js.map +1 -0
- package/dist/scan/types.d.ts +56 -0
- package/dist/scan/types.js +2 -0
- package/dist/scan/types.js.map +1 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.js +5 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/shared/package-info.d.ts +10 -0
- package/dist/shared/package-info.js +11 -0
- package/dist/shared/package-info.js.map +1 -0
- package/dist/shared/pool.d.ts +7 -0
- package/dist/shared/pool.js +25 -0
- package/dist/shared/pool.js.map +1 -0
- package/dist/shared/rate-limiter.d.ts +11 -0
- package/dist/shared/rate-limiter.js +26 -0
- package/dist/shared/rate-limiter.js.map +1 -0
- package/dist/shared/user-agent.d.ts +11 -0
- package/dist/shared/user-agent.js +12 -0
- package/dist/shared/user-agent.js.map +1 -0
- package/docs/SCANNING-ETHICS.md +52 -0
- package/docs/adr/0001-page-discovery-strategy.md +74 -0
- package/docs/adr/0002-json-report-output.md +54 -0
- package/docs/adr/0003-failon-threshold-and-exit-codes.md +72 -0
- package/docs/adr/0004-clause-mapping-from-axe-tags.md +81 -0
- package/docs/adr/0005-html-report-rendering.md +71 -0
- package/docs/adr/0006-pdf-report-rendering.md +58 -0
- package/docs/adr/0007-cli-argument-surface.md +73 -0
- package/docs/adr/0008-report-json-schema.md +59 -0
- package/package.json +73 -0
- package/schema/report.v1.json +156 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { fetchWithUserAgent } from '../shared/user-agent.js';
|
|
2
|
+
import { extractLinks, isCrawlableUrl, matchesExcludePattern } from './links.js';
|
|
3
|
+
import { fetchRobotsRules, isAllowedByRobots } from './robots.js';
|
|
4
|
+
/**
|
|
5
|
+
* Breadth-first fallback crawl, used when a site has no sitemap. Starts at
|
|
6
|
+
* baseUrl, follows same-origin links in document order, and stops once
|
|
7
|
+
* maxPages pages have been collected. robots.txt, the configured
|
|
8
|
+
* excludePaths, and non-page assets (mailto:, PDFs, images, ...) are all
|
|
9
|
+
* applied identically to the seed URL and to every discovered link, so a
|
|
10
|
+
* disallowed or excluded home page yields zero results rather than being
|
|
11
|
+
* scanned anyway.
|
|
12
|
+
*
|
|
13
|
+
* Deterministic: links are visited in a plain FIFO queue in the order they
|
|
14
|
+
* appear in each page's HTML, so the same input always produces the same
|
|
15
|
+
* page list in the same order.
|
|
16
|
+
*/
|
|
17
|
+
export async function crawlSite(options) {
|
|
18
|
+
const { baseUrl, maxPages, excludePaths = [], fetchFn = fetchWithUserAgent } = options;
|
|
19
|
+
const origin = new URL(baseUrl).origin;
|
|
20
|
+
const robotsRules = await fetchRobotsRules(origin, fetchFn);
|
|
21
|
+
const isPermitted = (url) => url.origin === origin &&
|
|
22
|
+
isCrawlableUrl(url) &&
|
|
23
|
+
!matchesExcludePattern(url.pathname, excludePaths) &&
|
|
24
|
+
isAllowedByRobots(url.pathname + url.search, robotsRules);
|
|
25
|
+
const startUrl = new URL(baseUrl);
|
|
26
|
+
startUrl.hash = '';
|
|
27
|
+
const queue = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const collected = [];
|
|
30
|
+
if (isPermitted(startUrl)) {
|
|
31
|
+
const startHref = startUrl.toString();
|
|
32
|
+
queue.push(startHref);
|
|
33
|
+
seen.add(startHref);
|
|
34
|
+
}
|
|
35
|
+
while (queue.length > 0 && collected.length < maxPages) {
|
|
36
|
+
const currentUrl = queue.shift();
|
|
37
|
+
if (currentUrl === undefined) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
const html = await fetchPage(fetchFn, currentUrl);
|
|
41
|
+
if (html === null) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
collected.push(currentUrl);
|
|
45
|
+
if (collected.length >= maxPages) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
enqueueLinks(extractLinks(html, currentUrl), isPermitted, seen, queue);
|
|
49
|
+
}
|
|
50
|
+
return collected;
|
|
51
|
+
}
|
|
52
|
+
async function fetchPage(fetchFn, url) {
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetchFn(url);
|
|
55
|
+
return response.ok ? await response.text() : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function enqueueLinks(hrefs, isPermitted, seen, queue) {
|
|
62
|
+
for (const href of hrefs) {
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = new URL(href);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
url.hash = '';
|
|
71
|
+
const normalized = url.toString();
|
|
72
|
+
if (seen.has(normalized) || !isPermitted(url)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
seen.add(normalized);
|
|
76
|
+
queue.push(normalized);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=crawl.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crawl.js","sourceRoot":"","sources":["../../src/discovery/crawl.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface DiscoverSiteOptions {
|
|
2
|
+
readonly baseUrl: string;
|
|
3
|
+
readonly maxPages: number;
|
|
4
|
+
/** Glob patterns matched against a URL pathname; matches are skipped. Defaults to none. */
|
|
5
|
+
readonly excludePaths?: readonly string[];
|
|
6
|
+
/** Injectable for tests; defaults to the global fetch (with User-Agent). */
|
|
7
|
+
readonly fetchFn?: typeof fetch;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the list of page URLs to scan for a site.
|
|
11
|
+
*
|
|
12
|
+
* Strategy: prefer the site's sitemap.xml — it is the owner's own
|
|
13
|
+
* declaration of which pages exist. If the root sitemap is missing or
|
|
14
|
+
* unparseable, fall back to a breadth-first crawl from baseUrl.
|
|
15
|
+
*
|
|
16
|
+
* Both strategies apply excludePaths and the non-page-asset filter
|
|
17
|
+
* identically. They differ on robots.txt: the crawl path obeys it (a
|
|
18
|
+
* disallowed page is never fetched, and a disallowed home page yields an
|
|
19
|
+
* empty list), while the sitemap path does not — a URL the owner put in
|
|
20
|
+
* their own sitemap is taken as intended for scanning.
|
|
21
|
+
*
|
|
22
|
+
* On the sitemap path, baseUrl is prepended when the sitemap did not list
|
|
23
|
+
* it, so the home page is always covered; the crawl path already starts
|
|
24
|
+
* from baseUrl. The result never exceeds maxPages.
|
|
25
|
+
*/
|
|
26
|
+
export declare function discoverSite(options: DiscoverSiteOptions): Promise<string[]>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { crawlSite } from './crawl.js';
|
|
2
|
+
import { discoverFromSitemap } from './discover.js';
|
|
3
|
+
import { SitemapError } from './sitemap.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the list of page URLs to scan for a site.
|
|
6
|
+
*
|
|
7
|
+
* Strategy: prefer the site's sitemap.xml — it is the owner's own
|
|
8
|
+
* declaration of which pages exist. If the root sitemap is missing or
|
|
9
|
+
* unparseable, fall back to a breadth-first crawl from baseUrl.
|
|
10
|
+
*
|
|
11
|
+
* Both strategies apply excludePaths and the non-page-asset filter
|
|
12
|
+
* identically. They differ on robots.txt: the crawl path obeys it (a
|
|
13
|
+
* disallowed page is never fetched, and a disallowed home page yields an
|
|
14
|
+
* empty list), while the sitemap path does not — a URL the owner put in
|
|
15
|
+
* their own sitemap is taken as intended for scanning.
|
|
16
|
+
*
|
|
17
|
+
* On the sitemap path, baseUrl is prepended when the sitemap did not list
|
|
18
|
+
* it, so the home page is always covered; the crawl path already starts
|
|
19
|
+
* from baseUrl. The result never exceeds maxPages.
|
|
20
|
+
*/
|
|
21
|
+
export async function discoverSite(options) {
|
|
22
|
+
const { baseUrl, maxPages, excludePaths = [], fetchFn } = options;
|
|
23
|
+
const shared = { baseUrl, maxPages, excludePaths, ...(fetchFn ? { fetchFn } : {}) };
|
|
24
|
+
try {
|
|
25
|
+
const pages = await discoverFromSitemap(shared);
|
|
26
|
+
return withBaseUrl(pages, baseUrl, maxPages);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (!(error instanceof SitemapError)) {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return crawlSite(shared);
|
|
34
|
+
}
|
|
35
|
+
function withBaseUrl(pages, baseUrl, maxPages) {
|
|
36
|
+
const normalized = new URL(baseUrl).toString();
|
|
37
|
+
const alreadyListed = pages.some((page) => {
|
|
38
|
+
try {
|
|
39
|
+
return new URL(page).toString() === normalized;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
if (alreadyListed) {
|
|
46
|
+
return [...pages];
|
|
47
|
+
}
|
|
48
|
+
return [normalized, ...pages].slice(0, maxPages);
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=discover-site.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discover-site.js","sourceRoot":"","sources":["../../src/discovery/discover-site.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAW5C;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,OAA4B;IAC7D,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,GAAG,EAAE,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAClE,MAAM,MAAM,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;IAEpF,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,mBAAmB,CAAC,MAAM,CAAC,CAAC;QAChD,OAAO,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,CAAC,KAAK,YAAY,YAAY,CAAC,EAAE,CAAC;YACrC,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC;AAC3B,CAAC;AAED,SAAS,WAAW,CAAC,KAAwB,EAAE,OAAe,EAAE,QAAgB;IAC9E,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC/C,MAAM,aAAa,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE;QACxC,IAAI,CAAC;YACH,OAAO,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,KAAK,UAAU,CAAC;QACjD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,IAAI,aAAa,EAAE,CAAC;QAClB,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC;IACpB,CAAC;IACD,OAAO,CAAC,UAAU,EAAE,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;AACnD,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface DiscoverFromSitemapOptions {
|
|
2
|
+
readonly baseUrl: string;
|
|
3
|
+
readonly maxPages: number;
|
|
4
|
+
/** Path to the root sitemap, relative to baseUrl. Defaults to "/sitemap.xml". */
|
|
5
|
+
readonly sitemapPath?: string;
|
|
6
|
+
/** Glob patterns matched against a URL pathname; matches are skipped. Defaults to none. */
|
|
7
|
+
readonly excludePaths?: readonly string[];
|
|
8
|
+
/** Injectable for tests; defaults to the global fetch. */
|
|
9
|
+
readonly fetchFn?: typeof fetch;
|
|
10
|
+
/** Guards against pathological or cyclic sitemap indexes. Defaults to 3. */
|
|
11
|
+
readonly maxSitemapDepth?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Discovers page URLs from a site's sitemap, following a sitemap index to
|
|
15
|
+
* its sub-sitemaps as needed. Off-origin entries are dropped (a sitemap
|
|
16
|
+
* should only ever list its own site, but nothing enforces that), non-page
|
|
17
|
+
* assets and excludePaths matches are filtered out (identically to
|
|
18
|
+
* crawlSite), duplicate URLs are collapsed, and discovery stops as soon as
|
|
19
|
+
* maxPages is reached.
|
|
20
|
+
*
|
|
21
|
+
* A failure to load the root sitemap is fatal (the caller should fall back
|
|
22
|
+
* to crawling). A failure on a sub-sitemap referenced by an index is not:
|
|
23
|
+
* that one sub-sitemap is skipped and discovery continues with the rest.
|
|
24
|
+
*/
|
|
25
|
+
export declare function discoverFromSitemap(options: DiscoverFromSitemapOptions): Promise<string[]>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fetchWithUserAgent } from '../shared/user-agent.js';
|
|
2
|
+
import { isCrawlableUrl, matchesExcludePattern } from './links.js';
|
|
3
|
+
import { parseSitemapXml, SitemapError } from './sitemap.js';
|
|
4
|
+
/**
|
|
5
|
+
* Discovers page URLs from a site's sitemap, following a sitemap index to
|
|
6
|
+
* its sub-sitemaps as needed. Off-origin entries are dropped (a sitemap
|
|
7
|
+
* should only ever list its own site, but nothing enforces that), non-page
|
|
8
|
+
* assets and excludePaths matches are filtered out (identically to
|
|
9
|
+
* crawlSite), duplicate URLs are collapsed, and discovery stops as soon as
|
|
10
|
+
* maxPages is reached.
|
|
11
|
+
*
|
|
12
|
+
* A failure to load the root sitemap is fatal (the caller should fall back
|
|
13
|
+
* to crawling). A failure on a sub-sitemap referenced by an index is not:
|
|
14
|
+
* that one sub-sitemap is skipped and discovery continues with the rest.
|
|
15
|
+
*/
|
|
16
|
+
export async function discoverFromSitemap(options) {
|
|
17
|
+
const { baseUrl, maxPages, sitemapPath = '/sitemap.xml', excludePaths = [], fetchFn = fetchWithUserAgent, maxSitemapDepth = 3, } = options;
|
|
18
|
+
const origin = new URL(baseUrl).origin;
|
|
19
|
+
const rootSitemapUrl = new URL(sitemapPath, baseUrl).toString();
|
|
20
|
+
const collected = [];
|
|
21
|
+
const seenPages = new Set();
|
|
22
|
+
const visitedSitemaps = new Set();
|
|
23
|
+
await visitSitemap(rootSitemapUrl, 0);
|
|
24
|
+
return collected.slice(0, maxPages);
|
|
25
|
+
async function visitSitemap(sitemapUrl, depth) {
|
|
26
|
+
if (collected.length >= maxPages) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (visitedSitemaps.has(sitemapUrl) || depth > maxSitemapDepth) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
visitedSitemaps.add(sitemapUrl);
|
|
33
|
+
let xml;
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetchFn(sitemapUrl);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`HTTP ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
xml = await response.text();
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
if (depth === 0) {
|
|
43
|
+
throw new SitemapError(`Cannot load sitemap at "${sitemapUrl}": ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const doc = parseSitemapXml(xml);
|
|
48
|
+
if (doc.kind === 'sitemapindex') {
|
|
49
|
+
for (const childUrl of doc.sitemaps) {
|
|
50
|
+
if (collected.length >= maxPages) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await visitSitemap(childUrl, depth + 1);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const pageUrl of doc.urls) {
|
|
58
|
+
if (collected.length >= maxPages) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (seenPages.has(pageUrl)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
seenPages.add(pageUrl);
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = new URL(pageUrl);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (parsed.origin !== origin ||
|
|
73
|
+
!isCrawlableUrl(parsed) ||
|
|
74
|
+
matchesExcludePattern(parsed.pathname, excludePaths)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
collected.push(pageUrl);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=discover.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discover.js","sourceRoot":"","sources":["../../src/discovery/discover.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { CrawlOptions } from './crawl.js';
|
|
2
|
+
export { crawlSite } from './crawl.js';
|
|
3
|
+
export type { DiscoverFromSitemapOptions } from './discover.js';
|
|
4
|
+
export { discoverFromSitemap } from './discover.js';
|
|
5
|
+
export type { DiscoverSiteOptions } from './discover-site.js';
|
|
6
|
+
export { discoverSite } from './discover-site.js';
|
|
7
|
+
export { extractLinks, isCrawlableUrl, matchesExcludePattern } from './links.js';
|
|
8
|
+
export type { RobotsRule } from './robots.js';
|
|
9
|
+
export { fetchRobotsRules, isAllowedByRobots, parseRobotsTxt } from './robots.js';
|
|
10
|
+
export type { SitemapDocument } from './sitemap.js';
|
|
11
|
+
export { parseSitemapXml, SitemapError } from './sitemap.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { crawlSite } from './crawl.js';
|
|
2
|
+
export { discoverFromSitemap } from './discover.js';
|
|
3
|
+
export { discoverSite } from './discover-site.js';
|
|
4
|
+
export { extractLinks, isCrawlableUrl, matchesExcludePattern } from './links.js';
|
|
5
|
+
export { fetchRobotsRules, isAllowedByRobots, parseRobotsTxt } from './robots.js';
|
|
6
|
+
export { parseSitemapXml, SitemapError } from './sitemap.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/discovery/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEpD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAEjF,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElF,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Extracts every <a href> from raw HTML, resolved against pageUrl. Href
|
|
2
|
+
* values that fail to resolve (empty, malformed) are silently skipped —
|
|
3
|
+
* this is a best-effort fallback crawler, not an HTML validator. */
|
|
4
|
+
export declare function extractLinks(html: string, pageUrl: string): string[];
|
|
5
|
+
/** True for http(s) links that don't point at a known non-page asset.
|
|
6
|
+
* Rejects mailto:/tel:/javascript: via the protocol check. */
|
|
7
|
+
export declare function isCrawlableUrl(url: URL): boolean;
|
|
8
|
+
/** Simple glob matching against a URL pathname: `*` matches any sequence of
|
|
9
|
+
* characters, and a pattern with no trailing wildcard also matches
|
|
10
|
+
* everything under it (e.g. "/impressum" matches "/impressum/legal"). */
|
|
11
|
+
export declare function matchesExcludePattern(pathname: string, patterns: readonly string[]): boolean;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const NON_PAGE_EXTENSIONS = new Set([
|
|
2
|
+
'.pdf',
|
|
3
|
+
'.jpg',
|
|
4
|
+
'.jpeg',
|
|
5
|
+
'.png',
|
|
6
|
+
'.gif',
|
|
7
|
+
'.svg',
|
|
8
|
+
'.webp',
|
|
9
|
+
'.ico',
|
|
10
|
+
'.css',
|
|
11
|
+
'.js',
|
|
12
|
+
'.mjs',
|
|
13
|
+
'.json',
|
|
14
|
+
'.xml',
|
|
15
|
+
'.zip',
|
|
16
|
+
'.mp4',
|
|
17
|
+
'.mp3',
|
|
18
|
+
'.woff',
|
|
19
|
+
'.woff2',
|
|
20
|
+
'.ttf',
|
|
21
|
+
'.eot',
|
|
22
|
+
'.doc',
|
|
23
|
+
'.docx',
|
|
24
|
+
'.xls',
|
|
25
|
+
'.xlsx',
|
|
26
|
+
]);
|
|
27
|
+
const HREF_PATTERN = /<a\s[^>]*?href\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
28
|
+
/** Extracts every <a href> from raw HTML, resolved against pageUrl. Href
|
|
29
|
+
* values that fail to resolve (empty, malformed) are silently skipped —
|
|
30
|
+
* this is a best-effort fallback crawler, not an HTML validator. */
|
|
31
|
+
export function extractLinks(html, pageUrl) {
|
|
32
|
+
const links = [];
|
|
33
|
+
for (const match of html.matchAll(HREF_PATTERN)) {
|
|
34
|
+
const raw = match[2] ?? match[3] ?? '';
|
|
35
|
+
if (raw.length === 0) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
links.push(new URL(raw, pageUrl).toString());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// ignore unparsable hrefs
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return links;
|
|
46
|
+
}
|
|
47
|
+
/** True for http(s) links that don't point at a known non-page asset.
|
|
48
|
+
* Rejects mailto:/tel:/javascript: via the protocol check. */
|
|
49
|
+
export function isCrawlableUrl(url) {
|
|
50
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const lastSegment = url.pathname.slice(url.pathname.lastIndexOf('/') + 1);
|
|
54
|
+
const lastDot = lastSegment.lastIndexOf('.');
|
|
55
|
+
if (lastDot === -1) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return !NON_PAGE_EXTENSIONS.has(lastSegment.slice(lastDot).toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
/** Simple glob matching against a URL pathname: `*` matches any sequence of
|
|
61
|
+
* characters, and a pattern with no trailing wildcard also matches
|
|
62
|
+
* everything under it (e.g. "/impressum" matches "/impressum/legal"). */
|
|
63
|
+
export function matchesExcludePattern(pathname, patterns) {
|
|
64
|
+
return patterns.some((pattern) => {
|
|
65
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
66
|
+
return new RegExp(`^${escaped}(?:/.*)?$`).test(pathname);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=links.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.js","sourceRoot":"","sources":["../../src/discovery/links.ts"],"names":[],"mappings":"AAAA,MAAM,mBAAmB,GAAwB,IAAI,GAAG,CAAC;IACvD,MAAM;IACN,MAAM;IACN,OAAO;IACP,MAAM;IACN,MAAM;IACN,MAAM;IACN,OAAO;IACP,MAAM;IACN,MAAM;IACN,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,MAAM;IACN,MAAM;IACN,MAAM;IACN,OAAO;IACP,QAAQ;IACR,MAAM;IACN,MAAM;IACN,MAAM;IACN,OAAO;IACP,MAAM;IACN,OAAO;CACR,CAAC,CAAC;AAEH,MAAM,YAAY,GAAG,8CAA8C,CAAC;AAEpE;;oEAEoE;AACpE,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,OAAe;IACxD,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;QAChD,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACvC,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrB,SAAS;QACX,CAAC;QACD,IAAI,CAAC;YACH,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC/C,CAAC;QAAC,MAAM,CAAC;YACP,0BAA0B;QAC5B,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;8DAC8D;AAC9D,MAAM,UAAU,cAAc,CAAC,GAAQ;IACrC,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC1D,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,WAAW,GAAG,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAC1E,MAAM,OAAO,GAAG,WAAW,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAC7C,IAAI,OAAO,KAAK,CAAC,CAAC,EAAE,CAAC;QACnB,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,CAAC,mBAAmB,CAAC,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;AAC5E,CAAC;AAED;;yEAEyE;AACzE,MAAM,UAAU,qBAAqB,CAAC,QAAgB,EAAE,QAA2B;IACjF,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE;QAC/B,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,oBAAoB,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QACnF,OAAO,IAAI,MAAM,CAAC,IAAI,OAAO,WAAW,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface RobotsRule {
|
|
2
|
+
readonly pattern: string;
|
|
3
|
+
readonly allow: boolean;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Parses the rule group matching `userAgent` (default "*") out of a
|
|
7
|
+
* robots.txt file. Only Allow/Disallow/User-agent are recognised — Sitemap,
|
|
8
|
+
* Crawl-delay, and anything else are ignored, since this parser exists only
|
|
9
|
+
* to decide whether a URL may be crawled.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseRobotsTxt(text: string, userAgent?: string): readonly RobotsRule[];
|
|
12
|
+
/**
|
|
13
|
+
* Standard robots.txt precedence: the most specific (longest) matching
|
|
14
|
+
* pattern wins; an Allow breaks a tie with a Disallow of equal length.
|
|
15
|
+
* With no matching rule, the path is allowed by default.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isAllowedByRobots(pathAndQuery: string, rules: readonly RobotsRule[]): boolean;
|
|
18
|
+
export declare function fetchRobotsRules(origin: string, fetchFn?: typeof fetch): Promise<readonly RobotsRule[]>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { fetchWithUserAgent } from '../shared/user-agent.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parses the rule group matching `userAgent` (default "*") out of a
|
|
4
|
+
* robots.txt file. Only Allow/Disallow/User-agent are recognised — Sitemap,
|
|
5
|
+
* Crawl-delay, and anything else are ignored, since this parser exists only
|
|
6
|
+
* to decide whether a URL may be crawled.
|
|
7
|
+
*/
|
|
8
|
+
export function parseRobotsTxt(text, userAgent = '*') {
|
|
9
|
+
const lines = stripBom(text).split(/\r?\n/);
|
|
10
|
+
const groups = [];
|
|
11
|
+
let current = null;
|
|
12
|
+
for (const rawLine of lines) {
|
|
13
|
+
const line = stripComment(rawLine).trim();
|
|
14
|
+
if (line.length === 0) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const separatorIndex = line.indexOf(':');
|
|
18
|
+
if (separatorIndex === -1) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const directive = line.slice(0, separatorIndex).trim().toLowerCase();
|
|
22
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
23
|
+
if (directive === 'user-agent') {
|
|
24
|
+
// A fresh User-agent line after rules have already been recorded
|
|
25
|
+
// starts a new group; consecutive User-agent lines with no rules yet
|
|
26
|
+
// belong to the same group (robots.txt allows grouping agents).
|
|
27
|
+
if (current === null || current.rules.length > 0) {
|
|
28
|
+
current = { agents: [], rules: [] };
|
|
29
|
+
groups.push(current);
|
|
30
|
+
}
|
|
31
|
+
current.agents.push(value.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
else if (directive === 'allow' || directive === 'disallow') {
|
|
34
|
+
if (current === null || (directive === 'disallow' && value.length === 0)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
current.rules.push({ pattern: value, allow: directive === 'allow' });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const group = groups.find((g) => g.agents.includes(userAgent.toLowerCase()));
|
|
41
|
+
return group?.rules ?? [];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Standard robots.txt precedence: the most specific (longest) matching
|
|
45
|
+
* pattern wins; an Allow breaks a tie with a Disallow of equal length.
|
|
46
|
+
* With no matching rule, the path is allowed by default.
|
|
47
|
+
*/
|
|
48
|
+
export function isAllowedByRobots(pathAndQuery, rules) {
|
|
49
|
+
let best = null;
|
|
50
|
+
for (const rule of rules) {
|
|
51
|
+
if (!matchesRobotsPattern(pathAndQuery, rule.pattern)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (best === null ||
|
|
55
|
+
rule.pattern.length > best.pattern.length ||
|
|
56
|
+
(rule.pattern.length === best.pattern.length && rule.allow && !best.allow)) {
|
|
57
|
+
best = rule;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return best === null || best.allow;
|
|
61
|
+
}
|
|
62
|
+
export async function fetchRobotsRules(origin, fetchFn = fetchWithUserAgent) {
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetchFn(new URL('/robots.txt', origin).toString());
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return parseRobotsTxt(await response.text());
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function matchesRobotsPattern(pathAndQuery, pattern) {
|
|
75
|
+
if (pattern.length === 0) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const endsWithDollar = pattern.endsWith('$');
|
|
79
|
+
const body = endsWithDollar ? pattern.slice(0, -1) : pattern;
|
|
80
|
+
const escaped = body.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
81
|
+
const regex = new RegExp(`^${escaped}${endsWithDollar ? '$' : ''}`);
|
|
82
|
+
return regex.test(pathAndQuery);
|
|
83
|
+
}
|
|
84
|
+
function stripBom(text) {
|
|
85
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
86
|
+
}
|
|
87
|
+
function stripComment(line) {
|
|
88
|
+
const hashIndex = line.indexOf('#');
|
|
89
|
+
return hashIndex === -1 ? line : line.slice(0, hashIndex);
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=robots.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"robots.js","sourceRoot":"","sources":["../../src/discovery/robots.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare class SitemapError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export type SitemapDocument = {
|
|
5
|
+
readonly kind: 'urlset';
|
|
6
|
+
readonly urls: readonly string[];
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: 'sitemapindex';
|
|
9
|
+
readonly sitemaps: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Parses a sitemaps.org XML document (urlset or sitemapindex), extracting
|
|
13
|
+
* only each entry's own <loc>. Extensions nested under a <url> — most
|
|
14
|
+
* commonly <image:image><image:loc>...</image:loc></image:image> in
|
|
15
|
+
* WordPress-generated sitemaps — are deliberately ignored: they share the
|
|
16
|
+
* tag name "loc" but are not page URLs.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseSitemapXml(xml: string): SitemapDocument;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
export class SitemapError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'SitemapError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const parser = new XMLParser({ ignoreAttributes: true });
|
|
9
|
+
/**
|
|
10
|
+
* Parses a sitemaps.org XML document (urlset or sitemapindex), extracting
|
|
11
|
+
* only each entry's own <loc>. Extensions nested under a <url> — most
|
|
12
|
+
* commonly <image:image><image:loc>...</image:loc></image:image> in
|
|
13
|
+
* WordPress-generated sitemaps — are deliberately ignored: they share the
|
|
14
|
+
* tag name "loc" but are not page URLs.
|
|
15
|
+
*/
|
|
16
|
+
export function parseSitemapXml(xml) {
|
|
17
|
+
const doc = parser.parse(xml);
|
|
18
|
+
if (isRecord(doc.urlset)) {
|
|
19
|
+
return { kind: 'urlset', urls: extractLocs(doc.urlset.url) };
|
|
20
|
+
}
|
|
21
|
+
if (isRecord(doc.sitemapindex)) {
|
|
22
|
+
return { kind: 'sitemapindex', sitemaps: extractLocs(doc.sitemapindex.sitemap) };
|
|
23
|
+
}
|
|
24
|
+
throw new SitemapError('Expected a <urlset> or <sitemapindex> root element');
|
|
25
|
+
}
|
|
26
|
+
function extractLocs(entries) {
|
|
27
|
+
const list = Array.isArray(entries) ? entries : entries === undefined ? [] : [entries];
|
|
28
|
+
return list
|
|
29
|
+
.filter(isRecord)
|
|
30
|
+
.map((entry) => entry.loc)
|
|
31
|
+
.filter((loc) => typeof loc === 'string');
|
|
32
|
+
}
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return typeof value === 'object' && value !== null;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=sitemap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sitemap.js","sourceRoot":"","sources":["../../src/discovery/sitemap.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF;AAMD,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAA4B,CAAC;IAEzD,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QACzB,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;IAC/D,CAAC;IAED,IAAI,QAAQ,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,CAAC;QAC/B,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,QAAQ,EAAE,WAAW,CAAC,GAAG,CAAC,YAAY,CAAC,OAAO,CAAC,EAAE,CAAC;IACnF,CAAC;IAED,MAAM,IAAI,YAAY,CAAC,oDAAoD,CAAC,CAAC;AAC/E,CAAC;AAED,SAAS,WAAW,CAAC,OAAgB;IACnC,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACvF,OAAO,IAAI;SACR,MAAM,CAAC,QAAQ,CAAC;SAChB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC;SACzB,MAAM,CAAC,CAAC,GAAG,EAAiB,EAAE,CAAC,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC;AAC7D,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,CAAC;AACrD,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SCHEMA_VERSION = 1;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ScanResult } from '../scan/index.js';
|
|
2
|
+
import type { Report, ReportTarget, ReportToolInfo } from './types.js';
|
|
3
|
+
export interface BuildReportOptions {
|
|
4
|
+
/** Overrides the generated-at timestamp; defaults to now. Injectable for tests. */
|
|
5
|
+
readonly generatedAt?: Date;
|
|
6
|
+
/** Overrides tool name/version; defaults to package.json. Injectable for tests. */
|
|
7
|
+
readonly tool?: ReportToolInfo;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Assembles a versioned, self-describing report from a scan result. Pure
|
|
11
|
+
* apart from the default clock and the one-time package.json read, both of
|
|
12
|
+
* which can be overridden via options.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildReport(scanResult: ScanResult, target: ReportTarget, options?: BuildReportOptions): Report;
|