@nitpicker/core 0.13.0 → 0.14.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.
@@ -7,7 +7,6 @@
7
7
  const STANDARD_ANALYZE_PLUGINS = [
8
8
  '@nitpicker/analyze-axe',
9
9
  '@nitpicker/analyze-lighthouse',
10
- '@nitpicker/analyze-main-contents',
11
10
  '@nitpicker/analyze-markuplint',
12
11
  '@nitpicker/analyze-search',
13
12
  '@nitpicker/analyze-textlint',
@@ -66,7 +66,7 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
66
66
  * declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
67
67
  * enables per-plugin progress tracking via Lanes.
68
68
  *
69
- * The analysis proceeds in two phases:
69
+ * The analysis proceeds in up to three phases:
70
70
  *
71
71
  * 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
72
72
  * page analysis tasks to the plugin's worker pool. Progress is
@@ -76,10 +76,32 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
76
76
  * sequentially in the main thread. These are lightweight checks
77
77
  * that don't need DOM access.
78
78
  *
79
- * On completion, three data entries are stored in the archive:
79
+ * 3. **Template classification phase** (opt-in via
80
+ * {@link AnalyzeOptions.classifyTemplates}) - Classifies every internal
81
+ * HTML page into a template group by DOM-structure similarity. Unlike
82
+ * phases 1-2 this is not a discovered `@nitpicker/analyze-*` plugin; it
83
+ * runs once, globally, after every batch from phases 1-2 has been
84
+ * accumulated (see `accumulatedPages` below for why it cannot run
85
+ * per-batch).
86
+ *
87
+ * On completion, data entries are stored in the archive:
80
88
  * - `analysis/report` - Full {@link Report} with headers and data
81
89
  * - `analysis/table` - The raw {@link Table} instance (serialized)
82
90
  * - SQL-backed analysis violation tables - Flat {@link Violation} records
91
+ * - `page_templates` SQL table - `templateKey` per page, written via
92
+ * `Archive.replacePageTemplates` and never round-tripped through
93
+ * `Table`/`Report`
94
+ *
95
+ * When `filter` resolves to zero plugins (only reachable when
96
+ * `classifyTemplates` is the sole reason for this call — see
97
+ * `cli/src/commands/analyze.ts`'s `--templates`-only bypass), this call
98
+ * contributes no plugin data of its own. To avoid silently wiping every
99
+ * column and violation from a *previous* run's plugins, this case skips
100
+ * `replaceAnalysisViolations` entirely and seeds the new `Table` from the
101
+ * archive's existing `analysis/report` (if any). This does not apply when
102
+ * one or more plugins ran: running a different plugin subset than a prior
103
+ * call has always fully replaced the report/table/violations with that
104
+ * subset's output, and that established contract is unchanged here.
83
105
  * @param filter - Optional list of plugin module names to run.
84
106
  * If omitted, all configured plugins are executed.
85
107
  * @param options - Optional settings for progress display.
package/lib/nitpicker.js CHANGED
@@ -7,6 +7,7 @@ import c from 'ansi-colors';
7
7
  import { importModules } from './import-modules.js';
8
8
  import { loadPluginSettings } from './load-plugin-settings.js';
9
9
  import { Table } from './table.js';
10
+ import { classifyPageTemplates } from './template-classification/classify-page-templates.js';
10
11
  import { UrlEventBus } from './url-event-bus.js';
11
12
  import { WorkerPool } from './worker/worker-pool.js';
12
13
  export { definePlugin } from './hooks/define-plugin.js';
@@ -29,6 +30,34 @@ const workerPath = path.resolve(__dirname, 'worker/worker.js');
29
30
  * Tracks the number of CPU cores so that worker pools scale with the host.
30
31
  */
31
32
  const DEFAULT_CONCURRENCY = Math.max(1, os.cpus().length);
33
+ /**
34
+ * Renders a `@d-zero/page-cluster` `ProgressEvent` as a short, human-readable
35
+ * fragment for the Lanes template-classification lane, including a
36
+ * done/total percentage wherever the event carries both halves of one
37
+ * (matching the `N/M (X%)` convention every other lane in this file uses).
38
+ * `pass0-signals` and `stage-b-start` carry only a running count with no
39
+ * corpus-wide total, so those two render count-only.
40
+ * @param event - Progress event forwarded from `resolvePageClusterKeys`.
41
+ * @returns A short status fragment, e.g. `"42/100 blocks (42%)"`.
42
+ */
43
+ function formatTemplateClassificationProgress(event) {
44
+ switch (event.phase) {
45
+ case 'pass0-signals': {
46
+ return `reading pages (${event.pagesSeen})`;
47
+ }
48
+ case 'pass1-block-complete': {
49
+ const percent = Math.round((event.blocksProcessed / event.totalBlocks) * 100);
50
+ return `${event.blocksProcessed}/${event.totalBlocks} blocks (${percent}%)`;
51
+ }
52
+ case 'pass1b-assign': {
53
+ const percent = Math.round((event.pagesAssigned / event.pagesToAssign) * 100);
54
+ return `${event.pagesAssigned}/${event.pagesToAssign} pages (${percent}%)`;
55
+ }
56
+ case 'stage-b-start': {
57
+ return `merging ${event.unitCount} units`;
58
+ }
59
+ }
60
+ }
32
61
  /**
33
62
  * Core orchestrator for running analyze plugins against a `.nitpicker` archive.
34
63
  *
@@ -112,7 +141,7 @@ export class Nitpicker extends EventEmitter {
112
141
  * declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
113
142
  * enables per-plugin progress tracking via Lanes.
114
143
  *
115
- * The analysis proceeds in two phases:
144
+ * The analysis proceeds in up to three phases:
116
145
  *
117
146
  * 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
118
147
  * page analysis tasks to the plugin's worker pool. Progress is
@@ -122,10 +151,32 @@ export class Nitpicker extends EventEmitter {
122
151
  * sequentially in the main thread. These are lightweight checks
123
152
  * that don't need DOM access.
124
153
  *
125
- * On completion, three data entries are stored in the archive:
154
+ * 3. **Template classification phase** (opt-in via
155
+ * {@link AnalyzeOptions.classifyTemplates}) - Classifies every internal
156
+ * HTML page into a template group by DOM-structure similarity. Unlike
157
+ * phases 1-2 this is not a discovered `@nitpicker/analyze-*` plugin; it
158
+ * runs once, globally, after every batch from phases 1-2 has been
159
+ * accumulated (see `accumulatedPages` below for why it cannot run
160
+ * per-batch).
161
+ *
162
+ * On completion, data entries are stored in the archive:
126
163
  * - `analysis/report` - Full {@link Report} with headers and data
127
164
  * - `analysis/table` - The raw {@link Table} instance (serialized)
128
165
  * - SQL-backed analysis violation tables - Flat {@link Violation} records
166
+ * - `page_templates` SQL table - `templateKey` per page, written via
167
+ * `Archive.replacePageTemplates` and never round-tripped through
168
+ * `Table`/`Report`
169
+ *
170
+ * When `filter` resolves to zero plugins (only reachable when
171
+ * `classifyTemplates` is the sole reason for this call — see
172
+ * `cli/src/commands/analyze.ts`'s `--templates`-only bypass), this call
173
+ * contributes no plugin data of its own. To avoid silently wiping every
174
+ * column and violation from a *previous* run's plugins, this case skips
175
+ * `replaceAnalysisViolations` entirely and seeds the new `Table` from the
176
+ * archive's existing `analysis/report` (if any). This does not apply when
177
+ * one or more plugins ran: running a different plugin subset than a prior
178
+ * call has always fully replaced the report/table/violations with that
179
+ * subset's output, and that established contract is unchanged here.
129
180
  * @param filter - Optional list of plugin module names to run.
130
181
  * If omitted, all configured plugins are executed.
131
182
  * @param options - Optional settings for progress display.
@@ -149,6 +200,34 @@ export class Nitpicker extends EventEmitter {
149
200
  const analyzeMods = await importModules(plugins);
150
201
  const lanes = options?.lanes;
151
202
  const table = new Table();
203
+ if (plugins.length === 0) {
204
+ // Only reachable via `--templates` with zero configured/selected
205
+ // plugins (see this method's JSDoc). Seed from whatever
206
+ // `analysis/report` already exists so this run — which contributes
207
+ // no plugin data of its own — doesn't wipe a previous run's columns.
208
+ try {
209
+ const previousReport = await this.archive.getData('analysis/report');
210
+ if (previousReport.pageData) {
211
+ table.addHeaders(previousReport.pageData.headers);
212
+ table.addData(previousReport.pageData.data);
213
+ }
214
+ }
215
+ catch (error) {
216
+ // ENOENT (no previous `analysis/report` — e.g. first analyze()
217
+ // call ever on this archive) is expected and starting from an
218
+ // empty table is correct. Anything else (corrupted JSON,
219
+ // permission error) is not silently swallowed: emit it so the
220
+ // user knows a previous run's data may not have been preserved,
221
+ // while still proceeding rather than blocking this run entirely.
222
+ if (error?.code !== 'ENOENT') {
223
+ const message = error instanceof Error ? error.message : String(error);
224
+ await this.emit('error', {
225
+ message: `Failed to read previous analysis/report before a zero-plugin run: ${message}`,
226
+ error: error instanceof Error ? error : null,
227
+ });
228
+ }
229
+ }
230
+ }
152
231
  for (const mod of analyzeMods) {
153
232
  if (!mod.headers) {
154
233
  continue;
@@ -180,7 +259,32 @@ export class Nitpicker extends EventEmitter {
180
259
  const label = pluginLabels.get(name) ?? name;
181
260
  lanes?.update(id, c.dim(`${label}: Waiting...`));
182
261
  }
262
+ // Template classification (opt-in, `--templates`) is a corpus-wide batch
263
+ // computation, not a per-page `AnalyzePlugin`, so it has its own lane
264
+ // outside `pluginLaneIds` and does not go through `analyzeMods`/`Worker`.
265
+ const templateClassificationLaneId = options?.classifyTemplates
266
+ ? eachPagePlugins.length
267
+ : null;
268
+ if (templateClassificationLaneId != null) {
269
+ lanes?.update(templateClassificationLaneId, c.dim('Template classification: Waiting...'));
270
+ }
271
+ // Accumulated across every `getPagesWithRefs` batch so template
272
+ // classification runs once, globally, after the loop below completes —
273
+ // never per-batch. Per-batch classification would produce template keys
274
+ // that are only comparable within their own batch (see
275
+ // `classifyPageTemplates`'s caller contract). `Page` instances are
276
+ // lightweight handles (HTML is fetched lazily via `getHtml()`), so
277
+ // accumulating every page across a 100,000-page batch size is not an
278
+ // OOM risk even for archives with hundreds of thousands of pages.
279
+ const accumulatedPages = [];
183
280
  await this.archive.getPagesWithRefs(100_000, async (pages) => {
281
+ if (options?.classifyTemplates) {
282
+ // Avoid `push(...pages)`: a 100,000-page batch can overflow V8's
283
+ // argument-spread limit even though the data itself fits in memory.
284
+ for (const page of pages) {
285
+ accumulatedPages.push(page);
286
+ }
287
+ }
184
288
  const urlEmitter = new UrlEventBus();
185
289
  // Phase 1: eachPage plugins (sequentially, pages in parallel)
186
290
  for (const [pluginSeqIndex, { plugin, modIndex }] of eachPagePlugins.entries()) {
@@ -354,13 +458,60 @@ export class Nitpicker extends EventEmitter {
354
458
  }, {
355
459
  withRefs: false,
356
460
  });
461
+ // Phase 3: template classification (opt-in). Deliberately outside the
462
+ // `getPagesWithRefs` loop above — it must run once, globally, over every
463
+ // accumulated page, not once per 100,000-page batch (see
464
+ // `accumulatedPages`'s JSDoc comment).
465
+ //
466
+ // Wrapped in try/catch (unlike phases 1-2, whose failures are already
467
+ // caught per-page/per-mod) because this phase has no finer-grained
468
+ // unit to catch around: a failure here must not discard the
469
+ // already-computed `table`/`allViolations` from phases 1-2, which are
470
+ // persisted in the unconditional tail below regardless of this
471
+ // phase's outcome.
472
+ if (templateClassificationLaneId != null) {
473
+ try {
474
+ const templateKeys = await classifyPageTemplates({
475
+ archive: this.archive,
476
+ pages: accumulatedPages,
477
+ // Only worth paying for when there's a lane to update —
478
+ // passing a defined callback at all (even a no-op one)
479
+ // demotes `resolvePageClusterKeys` off its byte-identical,
480
+ // yield-overhead-free sync path for corpora at or below its
481
+ // inline threshold (see @d-zero/page-cluster's own docs).
482
+ onProgress: lanes
483
+ ? (event) => {
484
+ lanes.update(templateClassificationLaneId, `Template classification: ${formatTemplateClassificationProgress(event)}%braille%`);
485
+ }
486
+ : undefined,
487
+ });
488
+ if (templateKeys.size > 0) {
489
+ await this.archive.replacePageTemplates(templateKeys);
490
+ }
491
+ lanes?.update(templateClassificationLaneId, c.green(`Template classification: Done (${templateKeys.size} pages)`));
492
+ }
493
+ catch (error) {
494
+ const message = error instanceof Error ? error.message : String(error);
495
+ lanes?.update(templateClassificationLaneId, c.red(`Template classification: Failed (${message})`));
496
+ await this.emit('error', {
497
+ message: `[template classification] ${message}`,
498
+ error: error instanceof Error ? error : null,
499
+ });
500
+ }
501
+ }
357
502
  const report = {
358
503
  name: 'general',
359
504
  pageData: table.toJSON(),
360
505
  };
361
506
  await this.archive.setData('analysis/report', report);
362
507
  await this.archive.setData('analysis/table', table);
363
- await this.archive.replaceAnalysisViolations(allViolations);
508
+ // See this method's JSDoc: a zero-plugin call (only reachable via
509
+ // `--templates` alone) contributes no violations of its own, so
510
+ // replacing the whole table with an empty array would silently erase
511
+ // every violation a previous run's plugins stored.
512
+ if (plugins.length > 0) {
513
+ await this.archive.replaceAnalysisViolations(allViolations);
514
+ }
364
515
  }
365
516
  /**
366
517
  * Loads and caches the plugin configuration from the user's config file.
@@ -0,0 +1,64 @@
1
+ import type { ProgressEvent } from '@d-zero/page-cluster/resolve-page-cluster-keys';
2
+ import type { Archive, Page } from '@nitpicker/crawler';
3
+ /**
4
+ * Options for {@link classifyPageTemplates}.
5
+ */
6
+ export interface ClassifyPageTemplatesOptions {
7
+ /** The archive to read pages/stylesheets from and to derive the cache key from. */
8
+ archive: Archive;
9
+ /**
10
+ * All pages already loaded for this `analyze()` run (see
11
+ * `Nitpicker.analyze`'s accumulation across `getPagesWithRefs` batches —
12
+ * classification must run once, globally, not per batch).
13
+ */
14
+ pages: readonly Page[];
15
+ /**
16
+ * Progress callback forwarded to `@d-zero/page-cluster`'s
17
+ * `resolvePageClusterKeys`, so long-running classification on large
18
+ * archives isn't silently unresponsive.
19
+ */
20
+ onProgress?: (event: ProgressEvent) => void;
21
+ }
22
+ /**
23
+ * Classifies every internal HTML page in the archive into a template group
24
+ * by DOM-structure similarity, using `@d-zero/page-cluster`.
25
+ *
26
+ * Result is cached, keyed by the archive file's path + size + mtime + page
27
+ * count (see {@link buildArchiveCacheKey}). **This only helps one narrow
28
+ * case**: a crash or Ctrl-C after this function completes but before
29
+ * `nitpicker.write()` finishes (writing back a multi-gigabyte archive can
30
+ * itself take a while) — re-running on the same, not-yet-rewritten archive
31
+ * file hits the cache instead of re-running the whole multi-pass clustering.
32
+ * It does **not** help a *second, fully successful* `analyze --templates`
33
+ * run on the same crawled data: `nitpicker.write()` always re-packages the
34
+ * archive (new mtime, and usually a different size), which changes the key
35
+ * on every completed run. There is no explicit `--force` escape hatch —
36
+ * any change to the archive changes the key, which is the correctness
37
+ * signal this cache relies on. The cache is **not** cleared at the start of
38
+ * a run (unlike the per-plugin `Cache` in `nitpicker.ts`) — its entire
39
+ * purpose is surviving the one crash-before-write window described above.
40
+ *
41
+ * There is no mid-computation checkpoint/resume: `resolvePageClusterKeys`
42
+ * holds state across its own internal multi-pass read and has no resume
43
+ * API, so a crash *during* classification means the next run classifies
44
+ * from scratch (this is a limitation of the upstream library, not something
45
+ * this function works around).
46
+ *
47
+ * Internally: the external library's `resolvePageClusterKeys` returns a
48
+ * `clusterKey` per page; this function's public naming is `templateKey`,
49
+ * to avoid colliding with nitpicker's unrelated pre-existing "isolated
50
+ * cluster" concept (`@nitpicker/query`'s `compute-isolated-clusters.ts`,
51
+ * link-reachability graph components — a completely different kind of
52
+ * grouping from DOM-structure similarity).
53
+ * @param options - See {@link ClassifyPageTemplatesOptions}.
54
+ * @returns Map from page URL (`page.url.href`) to its template key. Only
55
+ * internal HTML pages with retrievable HTML have an entry.
56
+ * @example
57
+ * ```ts
58
+ * const templateKeys = await classifyPageTemplates({ archive, pages });
59
+ * for (const [url, templateKey] of templateKeys) {
60
+ * table.addData({ [url]: { templateKey: { value: templateKey } } });
61
+ * }
62
+ * ```
63
+ */
64
+ export declare function classifyPageTemplates(options: ClassifyPageTemplatesOptions): Promise<Map<string, string>>;
@@ -0,0 +1,119 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { resolvePageClusterKeys } from '@d-zero/page-cluster/resolve-page-cluster-keys';
5
+ import { Cache } from '@d-zero/shared/cache';
6
+ import { collectPageStylesheetUrls } from './collect-page-stylesheet-urls.js';
7
+ import { createPageClusterFactory } from './create-page-cluster-factory.js';
8
+ /**
9
+ * Classifies every internal HTML page in the archive into a template group
10
+ * by DOM-structure similarity, using `@d-zero/page-cluster`.
11
+ *
12
+ * Result is cached, keyed by the archive file's path + size + mtime + page
13
+ * count (see {@link buildArchiveCacheKey}). **This only helps one narrow
14
+ * case**: a crash or Ctrl-C after this function completes but before
15
+ * `nitpicker.write()` finishes (writing back a multi-gigabyte archive can
16
+ * itself take a while) — re-running on the same, not-yet-rewritten archive
17
+ * file hits the cache instead of re-running the whole multi-pass clustering.
18
+ * It does **not** help a *second, fully successful* `analyze --templates`
19
+ * run on the same crawled data: `nitpicker.write()` always re-packages the
20
+ * archive (new mtime, and usually a different size), which changes the key
21
+ * on every completed run. There is no explicit `--force` escape hatch —
22
+ * any change to the archive changes the key, which is the correctness
23
+ * signal this cache relies on. The cache is **not** cleared at the start of
24
+ * a run (unlike the per-plugin `Cache` in `nitpicker.ts`) — its entire
25
+ * purpose is surviving the one crash-before-write window described above.
26
+ *
27
+ * There is no mid-computation checkpoint/resume: `resolvePageClusterKeys`
28
+ * holds state across its own internal multi-pass read and has no resume
29
+ * API, so a crash *during* classification means the next run classifies
30
+ * from scratch (this is a limitation of the upstream library, not something
31
+ * this function works around).
32
+ *
33
+ * Internally: the external library's `resolvePageClusterKeys` returns a
34
+ * `clusterKey` per page; this function's public naming is `templateKey`,
35
+ * to avoid colliding with nitpicker's unrelated pre-existing "isolated
36
+ * cluster" concept (`@nitpicker/query`'s `compute-isolated-clusters.ts`,
37
+ * link-reachability graph components — a completely different kind of
38
+ * grouping from DOM-structure similarity).
39
+ * @param options - See {@link ClassifyPageTemplatesOptions}.
40
+ * @returns Map from page URL (`page.url.href`) to its template key. Only
41
+ * internal HTML pages with retrievable HTML have an entry.
42
+ * @example
43
+ * ```ts
44
+ * const templateKeys = await classifyPageTemplates({ archive, pages });
45
+ * for (const [url, templateKey] of templateKeys) {
46
+ * table.addData({ [url]: { templateKey: { value: templateKey } } });
47
+ * }
48
+ * ```
49
+ */
50
+ export async function classifyPageTemplates(options) {
51
+ const { archive, pages, onProgress } = options;
52
+ const cache = getTemplateCache();
53
+ const cacheKey = await buildArchiveCacheKey(archive.filePath, pages.length);
54
+ if (cacheKey) {
55
+ const cached = await cache.load(cacheKey);
56
+ if (cached) {
57
+ return new Map(Object.entries(cached));
58
+ }
59
+ }
60
+ const stylesheetsByUrl = await collectPageStylesheetUrls(archive);
61
+ const { factory, getYieldedUrls } = createPageClusterFactory(pages, stylesheetsByUrl);
62
+ const clusterKeys = await resolvePageClusterKeys(factory, onProgress ? { onProgress } : undefined);
63
+ // Safe only after `resolvePageClusterKeys` has resolved — see
64
+ // `createPageClusterFactory`'s JSDoc for why.
65
+ const yieldedUrls = getYieldedUrls();
66
+ if (clusterKeys.length !== yieldedUrls.length) {
67
+ // Would silently mis-map template keys to the wrong URLs if allowed
68
+ // through — see createPageClusterFactory's JSDoc for why this should
69
+ // be structurally impossible, but a loud failure here is far
70
+ // preferable to a quiet mis-assignment.
71
+ throw new Error(`classifyPageTemplates: resolvePageClusterKeys returned ${clusterKeys.length} keys for ${yieldedUrls.length} yielded pages.`);
72
+ }
73
+ const result = new Map();
74
+ for (const [index, url] of yieldedUrls.entries()) {
75
+ result.set(url, clusterKeys[index]);
76
+ }
77
+ if (cacheKey) {
78
+ await cache.store(cacheKey, Object.fromEntries(result));
79
+ }
80
+ return result;
81
+ }
82
+ /**
83
+ * Lazily constructs the persistent template-classification cache. Kept as a
84
+ * function (rather than a module-level constant) so tests can point it at
85
+ * an isolated tmpDir-per-test without module-mocking `os.tmpdir`.
86
+ */
87
+ function getTemplateCache() {
88
+ return new Cache('nitpicker-templates', path.join(os.tmpdir(), 'nitpicker/cache/templates'));
89
+ }
90
+ /**
91
+ * Derives a cache key from the archive file's identity: its absolute path,
92
+ * byte size, mtime, and internal HTML page count. Byte size + mtime alone is
93
+ * a coarse signal — a same-path overwrite that happens to land on the same
94
+ * byte size within the filesystem's mtime tick (coarse mtime resolution,
95
+ * or two writes close enough together) would otherwise be indistinguishable
96
+ * from "unchanged". Page count is folded in as a cheap (already-computed,
97
+ * no extra query), second independent signal that most re-crawls
98
+ * (`--append` / `--inventory`) change; it does **not** catch every
99
+ * possible content change (e.g. `--retry-failed` replacing a failed page's
100
+ * content without changing the total page count), which is an accepted,
101
+ * documented gap rather than an attempt at a full content hash — hashing
102
+ * every page's HTML on every `analyze()` call would cost more than the
103
+ * cache is meant to save.
104
+ * @param archiveFilePath - `Archive.filePath` of the archive being analyzed.
105
+ * @param pageCount - Number of pages passed to {@link classifyPageTemplates}
106
+ * for this run (`pages.length`, before internal/HTML filtering).
107
+ * @returns The cache key, or `null` if the file's stats cannot be read
108
+ * (falls back to always recomputing rather than caching under the wrong
109
+ * key).
110
+ */
111
+ async function buildArchiveCacheKey(archiveFilePath, pageCount) {
112
+ try {
113
+ const stats = await stat(archiveFilePath);
114
+ return `${archiveFilePath}:${stats.size}:${stats.mtimeMs}:${pageCount}`;
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
@@ -0,0 +1,32 @@
1
+ import type { Archive } from '@nitpicker/crawler';
2
+ /**
3
+ * Collects, for every page that references at least one CSS stylesheet, the
4
+ * set of stylesheet URLs it references.
5
+ *
6
+ * Reads in three narrow passes instead of one wide JOIN, to avoid
7
+ * materializing the full page×stylesheet edge set at once — a real
8
+ * 486,000-page archive has ~2.5 million such edges, and a single JOIN
9
+ * projecting both URL strings for all of them is a transient allocation
10
+ * spike well beyond what any single page's data actually needs:
11
+ *
12
+ * 1. Resolve which `resource_items` rows are classified `'css'`
13
+ * (`content_type_refs.category`) and their stylesheet URL.
14
+ * 2. Resolve `resource_ref_edges` for just those CSS resource ids, in
15
+ * chunks, projecting only the two integer ids per row.
16
+ * 3. Resolve the page ids collected in step 2 to their URLs.
17
+ *
18
+ * Identical stylesheet-URL arrays are interned across pages: pages sharing a
19
+ * template share the same CSS set, so the number of distinct arrays
20
+ * converges to a few hundred even across hundreds of thousands of pages.
21
+ * @param archive - The archive to read from.
22
+ * @returns Map from page URL (parseUrl-normalized `href`, matching
23
+ * `Page.url.href`) to the stylesheet URLs it references. Pages that
24
+ * reference no CSS resource have no entry — callers should default to an
25
+ * empty array on a missed lookup.
26
+ * @example
27
+ * ```ts
28
+ * const stylesheetsByUrl = await collectPageStylesheetUrls(archive);
29
+ * const hrefs = stylesheetsByUrl.get(page.url.href) ?? [];
30
+ * ```
31
+ */
32
+ export declare function collectPageStylesheetUrls(archive: Archive): Promise<Map<string, readonly string[]>>;
@@ -0,0 +1,130 @@
1
+ import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
2
+ import { eachSplitted } from '@nitpicker/crawler';
3
+ /**
4
+ * SQLite has a hard cap of 999 bound variables per statement. A real archive
5
+ * can have far more than 999 CSS-referencing edges or pages, so every
6
+ * `whereIn` below is chunked at this size.
7
+ *
8
+ * Independently duplicated (not shared/imported) from `@nitpicker/query`'s
9
+ * `compute-isolated-clusters.ts`, which defines the identical
10
+ * `SQLITE_IN_CHUNK = 500` for the same reason. If this margin below 999 ever
11
+ * needs revisiting, update both — there is no shared module either package
12
+ * depends on to hold one copy.
13
+ */
14
+ const SQLITE_IN_CHUNK = 500;
15
+ /**
16
+ * Collects, for every page that references at least one CSS stylesheet, the
17
+ * set of stylesheet URLs it references.
18
+ *
19
+ * Reads in three narrow passes instead of one wide JOIN, to avoid
20
+ * materializing the full page×stylesheet edge set at once — a real
21
+ * 486,000-page archive has ~2.5 million such edges, and a single JOIN
22
+ * projecting both URL strings for all of them is a transient allocation
23
+ * spike well beyond what any single page's data actually needs:
24
+ *
25
+ * 1. Resolve which `resource_items` rows are classified `'css'`
26
+ * (`content_type_refs.category`) and their stylesheet URL.
27
+ * 2. Resolve `resource_ref_edges` for just those CSS resource ids, in
28
+ * chunks, projecting only the two integer ids per row.
29
+ * 3. Resolve the page ids collected in step 2 to their URLs.
30
+ *
31
+ * Identical stylesheet-URL arrays are interned across pages: pages sharing a
32
+ * template share the same CSS set, so the number of distinct arrays
33
+ * converges to a few hundred even across hundreds of thousands of pages.
34
+ * @param archive - The archive to read from.
35
+ * @returns Map from page URL (parseUrl-normalized `href`, matching
36
+ * `Page.url.href`) to the stylesheet URLs it references. Pages that
37
+ * reference no CSS resource have no entry — callers should default to an
38
+ * empty array on a missed lookup.
39
+ * @example
40
+ * ```ts
41
+ * const stylesheetsByUrl = await collectPageStylesheetUrls(archive);
42
+ * const hrefs = stylesheetsByUrl.get(page.url.href) ?? [];
43
+ * ```
44
+ */
45
+ export async function collectPageStylesheetUrls(archive) {
46
+ const knex = archive.getKnex();
47
+ const cssResources = (await knex('resource_items as ri')
48
+ .join('content_type_refs as ctr', 'ctr.id', 'ri.content_type_id')
49
+ .where('ctr.category', 'css')
50
+ .select('ri.id as id', 'ri.url_id as urlId'));
51
+ // A CSS resource identified by a `data:` URI (routed to `blob_refs`
52
+ // instead of `url_refs`, `resource_items.url_blob_id`) has no stylesheet
53
+ // URL to report — skip it rather than passing a `null` into `whereIn`.
54
+ const cssResourcesWithUrl = cssResources.filter((r) => r.urlId != null);
55
+ if (cssResourcesWithUrl.length === 0) {
56
+ return new Map();
57
+ }
58
+ const cssUrlById = new Map();
59
+ await eachSplitted(cssResourcesWithUrl.map((r) => r.urlId), SQLITE_IN_CHUNK, async (chunk) => {
60
+ const rows = (await knex('url_refs').whereIn('id', chunk).select('id', 'url'));
61
+ for (const row of rows) {
62
+ cssUrlById.set(row.id, row.url);
63
+ }
64
+ });
65
+ const cssUrlByResourceId = new Map();
66
+ for (const r of cssResourcesWithUrl) {
67
+ const url = cssUrlById.get(r.urlId);
68
+ if (url) {
69
+ cssUrlByResourceId.set(r.id, url);
70
+ }
71
+ }
72
+ const resourceIdsByPageId = new Map();
73
+ await eachSplitted(cssResourcesWithUrl.map((r) => r.id), SQLITE_IN_CHUNK, async (chunk) => {
74
+ const rows = (await knex('resource_ref_edges')
75
+ .whereIn('resource_id', chunk)
76
+ .select('resource_id as resourceId', 'page_id as pageId'));
77
+ // Avoid `push(...rows)`: on large real archives this chunk array can
78
+ // be large enough to overflow V8's argument-spread limit even though
79
+ // the underlying data itself fits in memory.
80
+ for (const row of rows) {
81
+ const existing = resourceIdsByPageId.get(row.pageId);
82
+ if (existing) {
83
+ existing.push(row.resourceId);
84
+ }
85
+ else {
86
+ resourceIdsByPageId.set(row.pageId, [row.resourceId]);
87
+ }
88
+ }
89
+ });
90
+ if (resourceIdsByPageId.size === 0) {
91
+ return new Map();
92
+ }
93
+ const pageUrlById = new Map();
94
+ await eachSplitted([...resourceIdsByPageId.keys()], SQLITE_IN_CHUNK, async (chunk) => {
95
+ const rows = (await knex('content_items as ci')
96
+ .join('url_refs as ur', 'ur.id', 'ci.url_id')
97
+ .whereIn('ci.id', chunk)
98
+ .select('ci.id as id', 'ur.url as url'));
99
+ for (const row of rows) {
100
+ pageUrlById.set(row.id, row.url);
101
+ }
102
+ });
103
+ const internedSets = new Map();
104
+ const result = new Map();
105
+ for (const [pageId, resourceIds] of resourceIdsByPageId) {
106
+ const pageUrlRaw = pageUrlById.get(pageId);
107
+ if (!pageUrlRaw) {
108
+ continue;
109
+ }
110
+ const parsed = parseUrl(pageUrlRaw);
111
+ if (!parsed) {
112
+ continue;
113
+ }
114
+ const urls = resourceIds
115
+ .map((id) => cssUrlByResourceId.get(id))
116
+ .filter((url) => url != null)
117
+ .toSorted();
118
+ if (urls.length === 0) {
119
+ continue;
120
+ }
121
+ const internKey = urls.join(' ');
122
+ let interned = internedSets.get(internKey);
123
+ if (!interned) {
124
+ interned = Object.freeze(urls);
125
+ internedSets.set(internKey, interned);
126
+ }
127
+ result.set(parsed.href, interned);
128
+ }
129
+ return result;
130
+ }
@@ -0,0 +1,55 @@
1
+ import type { PageFactory } from '@d-zero/page-cluster/resolve-page-cluster-keys';
2
+ import type { Page } from '@nitpicker/crawler';
3
+ /**
4
+ * Builds the `PageFactory` that `@d-zero/page-cluster`'s
5
+ * `resolvePageClusterKeys` reads the corpus through, plus a way to read back
6
+ * the URL list it actually yielded (see below for why that can't be known
7
+ * synchronously up front).
8
+ *
9
+ * Only internal HTML pages (`Page.isInternalPage()` — content-type based,
10
+ * matching the "a page is judged by content-type, not `isTarget`" archive
11
+ * invariant) with retrievable HTML are yielded. A page can be an internal
12
+ * HTML page and still have no retrievable HTML (e.g. a skipped or
13
+ * self-heal-affected row) — `page.getHtml()` returning falsy is only known
14
+ * by actually calling it, not from any synchronous field.
15
+ *
16
+ * `resolvePageClusterKeys` calls the returned factory one or more times
17
+ * (once for corpora at or below its inline threshold, twice above it — see
18
+ * its own docs) to re-read the corpus. Whichever pages end up excluded
19
+ * (empty HTML) must be excluded **identically** on every call, or the
20
+ * pass-to-pass page sequence drifts and the library's same-order contract
21
+ * breaks. Excluding by `isInternalPage()` is safe (synchronous,
22
+ * deterministic per page). Excluding by empty `getHtml()` is also safe,
23
+ * but only because the archive is immutable for the duration of one
24
+ * `analyze()` run — the same page's HTML never changes between calls.
25
+ *
26
+ * `getYieldedUrls()` reflects the URLs yielded by the **most recently
27
+ * completed** full iteration of the factory's generator, deliberately not a
28
+ * separate up-front probe pass over every candidate's HTML: every documented
29
+ * call shape of `resolvePageClusterKeys` fully drains the factory it's given
30
+ * (that's what "reading the factory" means in its own docs), so by the time
31
+ * `resolvePageClusterKeys` resolves, at least one full drain has already
32
+ * happened and `getYieldedUrls()` reflects it — a dedicated probe pass would
33
+ * only add a full corpus-wide decompression sweep beyond the library's own
34
+ * 1-2, for no benefit. `classifyPageTemplates` still defends against a
35
+ * violated assumption here with a hard length check against
36
+ * `resolvePageClusterKeys`'s result array.
37
+ * @param pages - Candidate pages (as already loaded by `Nitpicker.analyze()`
38
+ * — this function does not re-query the archive).
39
+ * @param stylesheetsByUrl - Output of `collectPageStylesheetUrls`, keyed by
40
+ * `page.url.href`.
41
+ * @returns `factory` to pass to `resolvePageClusterKeys`, and
42
+ * `getYieldedUrls()` — call this only *after* `resolvePageClusterKeys`
43
+ * resolves; its result array has exactly the length and order of
44
+ * `getYieldedUrls()` at that point.
45
+ * @example
46
+ * ```ts
47
+ * const { factory, getYieldedUrls } = createPageClusterFactory(pages, stylesheetsByUrl);
48
+ * const clusterKeys = await resolvePageClusterKeys(factory);
49
+ * const yieldedUrls = getYieldedUrls(); // safe to read now
50
+ * ```
51
+ */
52
+ export declare function createPageClusterFactory(pages: readonly Page[], stylesheetsByUrl: ReadonlyMap<string, readonly string[]>): {
53
+ factory: PageFactory;
54
+ getYieldedUrls: () => readonly string[];
55
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Builds the `PageFactory` that `@d-zero/page-cluster`'s
3
+ * `resolvePageClusterKeys` reads the corpus through, plus a way to read back
4
+ * the URL list it actually yielded (see below for why that can't be known
5
+ * synchronously up front).
6
+ *
7
+ * Only internal HTML pages (`Page.isInternalPage()` — content-type based,
8
+ * matching the "a page is judged by content-type, not `isTarget`" archive
9
+ * invariant) with retrievable HTML are yielded. A page can be an internal
10
+ * HTML page and still have no retrievable HTML (e.g. a skipped or
11
+ * self-heal-affected row) — `page.getHtml()` returning falsy is only known
12
+ * by actually calling it, not from any synchronous field.
13
+ *
14
+ * `resolvePageClusterKeys` calls the returned factory one or more times
15
+ * (once for corpora at or below its inline threshold, twice above it — see
16
+ * its own docs) to re-read the corpus. Whichever pages end up excluded
17
+ * (empty HTML) must be excluded **identically** on every call, or the
18
+ * pass-to-pass page sequence drifts and the library's same-order contract
19
+ * breaks. Excluding by `isInternalPage()` is safe (synchronous,
20
+ * deterministic per page). Excluding by empty `getHtml()` is also safe,
21
+ * but only because the archive is immutable for the duration of one
22
+ * `analyze()` run — the same page's HTML never changes between calls.
23
+ *
24
+ * `getYieldedUrls()` reflects the URLs yielded by the **most recently
25
+ * completed** full iteration of the factory's generator, deliberately not a
26
+ * separate up-front probe pass over every candidate's HTML: every documented
27
+ * call shape of `resolvePageClusterKeys` fully drains the factory it's given
28
+ * (that's what "reading the factory" means in its own docs), so by the time
29
+ * `resolvePageClusterKeys` resolves, at least one full drain has already
30
+ * happened and `getYieldedUrls()` reflects it — a dedicated probe pass would
31
+ * only add a full corpus-wide decompression sweep beyond the library's own
32
+ * 1-2, for no benefit. `classifyPageTemplates` still defends against a
33
+ * violated assumption here with a hard length check against
34
+ * `resolvePageClusterKeys`'s result array.
35
+ * @param pages - Candidate pages (as already loaded by `Nitpicker.analyze()`
36
+ * — this function does not re-query the archive).
37
+ * @param stylesheetsByUrl - Output of `collectPageStylesheetUrls`, keyed by
38
+ * `page.url.href`.
39
+ * @returns `factory` to pass to `resolvePageClusterKeys`, and
40
+ * `getYieldedUrls()` — call this only *after* `resolvePageClusterKeys`
41
+ * resolves; its result array has exactly the length and order of
42
+ * `getYieldedUrls()` at that point.
43
+ * @example
44
+ * ```ts
45
+ * const { factory, getYieldedUrls } = createPageClusterFactory(pages, stylesheetsByUrl);
46
+ * const clusterKeys = await resolvePageClusterKeys(factory);
47
+ * const yieldedUrls = getYieldedUrls(); // safe to read now
48
+ * ```
49
+ */
50
+ export function createPageClusterFactory(pages, stylesheetsByUrl) {
51
+ const candidates = pages.filter((page) => page.isInternalPage());
52
+ let yieldedUrls = [];
53
+ const factory = () => yieldPageClusterSignals(candidates, stylesheetsByUrl, (urls) => {
54
+ yieldedUrls = urls;
55
+ });
56
+ return { factory, getYieldedUrls: () => yieldedUrls };
57
+ }
58
+ /**
59
+ * The actual per-call iterator body for {@link createPageClusterFactory}'s
60
+ * `PageFactory`. HTML is fetched lazily, one page at a time, so the archive
61
+ * never holds more than one page's decompressed HTML in memory regardless
62
+ * of corpus size — matching `@d-zero/page-cluster`'s own memory-bounding
63
+ * design for its multi-pass reads.
64
+ * @param candidates - Pre-filtered internal HTML pages, in fixed order.
65
+ * @param stylesheetsByUrl - See {@link createPageClusterFactory}.
66
+ * @param onDrained - Called with every yielded URL, in order, once this
67
+ * particular iteration reaches the end of `candidates` — never called if
68
+ * the consumer stops iterating early.
69
+ * @yields {PageClusterSignals} One per candidate page with retrievable HTML.
70
+ */
71
+ async function* yieldPageClusterSignals(candidates, stylesheetsByUrl, onDrained) {
72
+ const yieldedUrls = [];
73
+ for (const page of candidates) {
74
+ const html = await page.getHtml();
75
+ if (!html) {
76
+ continue;
77
+ }
78
+ const url = page.url;
79
+ yieldedUrls.push(url.href);
80
+ yield {
81
+ paths: url.paths,
82
+ stylesheetHrefs: stylesheetsByUrl.get(url.href) ?? [],
83
+ html,
84
+ host: url.port ? `${url.hostname}:${url.port}` : url.hostname,
85
+ };
86
+ }
87
+ onDrained(yieldedUrls);
88
+ }
package/lib/types.d.ts CHANGED
@@ -44,6 +44,17 @@ export interface AnalyzeOptions {
44
44
  readonly lanes?: Lanes;
45
45
  /** When `true`, outputs plain-text progress lines instead of animated Lanes. */
46
46
  readonly verbose?: boolean;
47
+ /**
48
+ * When `true`, runs an additional, opt-in classification phase that
49
+ * groups internal HTML pages by DOM-structure similarity (using
50
+ * `@d-zero/page-cluster`), independent of the configured
51
+ * `@nitpicker/analyze-*` plugins. Results are added to the report table
52
+ * as a `templateKey` column. Defaults to `false` — the algorithm's
53
+ * runtime on very large archives is not yet characterized, so it is
54
+ * never run implicitly.
55
+ * @see {@link ./template-classification/classify-page-templates.ts!classifyPageTemplates}
56
+ */
57
+ readonly classifyTemplates?: boolean;
47
58
  }
48
59
  /**
49
60
  * CLI-specified overrides for individual plugin settings.
@@ -70,11 +81,6 @@ export interface PluginOverrides {
70
81
  /** CSS selector to narrow the search scope. */
71
82
  scope?: string;
72
83
  };
73
- /** Overrides for `@nitpicker/analyze-main-contents` plugin settings. */
74
- '@nitpicker/analyze-main-contents'?: {
75
- /** CSS selector for main content detection. */
76
- mainContentSelector?: string;
77
- };
78
84
  /** Overrides for `@nitpicker/analyze-axe` plugin settings. */
79
85
  '@nitpicker/analyze-axe'?: {
80
86
  /** BCP 47 language tag for locale. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitpicker/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Plugin-based page analysis engine for Nitpicker",
5
5
  "author": "D-ZERO",
6
6
  "license": "Apache-2.0",
@@ -28,12 +28,13 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@d-zero/dealer": "1.9.4",
31
+ "@d-zero/page-cluster": "0.3.1",
31
32
  "@d-zero/shared": "0.22.2",
32
- "@nitpicker/crawler": "0.13.0",
33
- "@nitpicker/types": "0.13.0",
33
+ "@nitpicker/crawler": "0.14.0",
34
+ "@nitpicker/types": "0.14.0",
34
35
  "ansi-colors": "4.1.3",
35
36
  "cosmiconfig": "9.0.2",
36
37
  "jsdom": "29.1.1"
37
38
  },
38
- "gitHead": "2cd462ea6b183f4462b7caba27b4db04aaba7c70"
39
+ "gitHead": "d6e1894a29aa29ca94785d9db0acfc3acc74764b"
39
40
  }