@nitpicker/core 0.4.1

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +191 -0
  3. package/README.md +13 -0
  4. package/lib/discover-analyze-plugins.d.ts +14 -0
  5. package/lib/discover-analyze-plugins.js +34 -0
  6. package/lib/find-nitpicker-modules-dir.d.ts +12 -0
  7. package/lib/find-nitpicker-modules-dir.js +23 -0
  8. package/lib/hooks/actions.d.ts +9 -0
  9. package/lib/hooks/actions.js +9 -0
  10. package/lib/hooks/child-process.d.ts +1 -0
  11. package/lib/hooks/child-process.js +34 -0
  12. package/lib/hooks/define-plugin.d.ts +68 -0
  13. package/lib/hooks/define-plugin.js +69 -0
  14. package/lib/hooks/index.d.ts +1 -0
  15. package/lib/hooks/index.js +1 -0
  16. package/lib/hooks/runner.d.ts +10 -0
  17. package/lib/hooks/runner.js +32 -0
  18. package/lib/import-modules.d.ts +24 -0
  19. package/lib/import-modules.js +38 -0
  20. package/lib/index.d.ts +5 -0
  21. package/lib/index.js +5 -0
  22. package/lib/load-plugin-settings.d.ts +40 -0
  23. package/lib/load-plugin-settings.js +85 -0
  24. package/lib/nitpicker.d.ts +127 -0
  25. package/lib/nitpicker.js +338 -0
  26. package/lib/page-analysis-worker.d.ts +48 -0
  27. package/lib/page-analysis-worker.js +98 -0
  28. package/lib/read-plugin-labels.d.ts +15 -0
  29. package/lib/read-plugin-labels.js +30 -0
  30. package/lib/table.d.ts +75 -0
  31. package/lib/table.js +132 -0
  32. package/lib/types.d.ts +264 -0
  33. package/lib/types.js +1 -0
  34. package/lib/url-event-bus.d.ts +32 -0
  35. package/lib/url-event-bus.js +20 -0
  36. package/lib/utils.d.ts +36 -0
  37. package/lib/utils.js +43 -0
  38. package/lib/worker/run-in-worker.d.ts +51 -0
  39. package/lib/worker/run-in-worker.js +120 -0
  40. package/lib/worker/runner.d.ts +25 -0
  41. package/lib/worker/runner.js +31 -0
  42. package/lib/worker/types.d.ts +23 -0
  43. package/lib/worker/types.js +1 -0
  44. package/lib/worker/worker.d.ts +27 -0
  45. package/lib/worker/worker.js +53 -0
  46. package/package.json +36 -0
  47. package/src/discover-analyze-plugins.spec.ts +21 -0
  48. package/src/discover-analyze-plugins.ts +37 -0
  49. package/src/hooks/define-plugin.spec.ts +38 -0
  50. package/src/hooks/define-plugin.ts +73 -0
  51. package/src/hooks/index.ts +1 -0
  52. package/src/import-modules.spec.ts +150 -0
  53. package/src/import-modules.ts +45 -0
  54. package/src/index.ts +5 -0
  55. package/src/load-plugin-settings.spec.ts +192 -0
  56. package/src/load-plugin-settings.ts +99 -0
  57. package/src/nitpicker.ts +418 -0
  58. package/src/page-analysis-worker.spec.ts +287 -0
  59. package/src/page-analysis-worker.ts +131 -0
  60. package/src/read-plugin-labels.spec.ts +151 -0
  61. package/src/read-plugin-labels.ts +37 -0
  62. package/src/table.spec.ts +83 -0
  63. package/src/table.ts +149 -0
  64. package/src/types.ts +289 -0
  65. package/src/url-event-bus.spec.ts +28 -0
  66. package/src/url-event-bus.ts +33 -0
  67. package/src/worker/run-in-worker.ts +155 -0
  68. package/src/worker/runner.ts +38 -0
  69. package/src/worker/types.ts +25 -0
  70. package/src/worker/worker.ts +64 -0
  71. package/tsconfig.json +11 -0
  72. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,40 @@
1
+ import type { Config } from './types.js';
2
+ /**
3
+ * Loads the analyze plugin configuration from the user's config file.
4
+ *
5
+ * Uses cosmiconfig to search the filesystem from `process.cwd()` upward
6
+ * for a Nitpicker configuration file. The external config format
7
+ * (`ConfigJSON` from `@nitpicker/types`) is normalized into the internal
8
+ * {@link Config} model:
9
+ *
10
+ * - `config.plugins.analyze` (object keyed by module name with settings)
11
+ * is converted to an ordered `Plugin[]` array
12
+ * - Boolean `true` settings are normalized to empty objects `{}`
13
+ * - The config file path is attached to each plugin for relative path resolution
14
+ * @param defaultConfig - Optional partial config to merge as defaults.
15
+ * Plugin lists are concatenated (defaults first, then discovered plugins).
16
+ * @returns Fully resolved {@link Config} with the `analyze` plugin list.
17
+ * @example
18
+ * ```ts
19
+ * // .nitpickerrc.json
20
+ * // {
21
+ * // "plugins": {
22
+ * // "analyze": {
23
+ * // "@nitpicker/analyze-axe": { "lang": "ja" },
24
+ * // "@nitpicker/analyze-markuplint": true
25
+ * // }
26
+ * // }
27
+ * // }
28
+ *
29
+ * const config = await loadPluginSettings();
30
+ * // config.analyze = [
31
+ * // { name: '@nitpicker/analyze-axe', module: '@nitpicker/analyze-axe',
32
+ * // configFilePath: '/path/to/.nitpickerrc.json', settings: { lang: 'ja' } },
33
+ * // { name: '@nitpicker/analyze-markuplint', module: '@nitpicker/analyze-markuplint',
34
+ * // configFilePath: '/path/to/.nitpickerrc.json', settings: {} },
35
+ * // ]
36
+ * ```
37
+ * @see {@link ./types.ts!Config} for the output type
38
+ * @see {@link ./types.ts!Plugin} for individual plugin entries
39
+ */
40
+ export declare function loadPluginSettings(defaultConfig?: Partial<Config>): Promise<Config>;
@@ -0,0 +1,85 @@
1
+ import { cosmiconfig } from 'cosmiconfig';
2
+ import { discoverAnalyzePlugins } from './discover-analyze-plugins.js';
3
+ /**
4
+ * The cosmiconfig module name used for config file discovery.
5
+ * Searches for: `.nitpickerrc`, `.nitpickerrc.json`, `.nitpickerrc.yaml`,
6
+ * `nitpicker.config.js`, `nitpicker.config.cjs`, or a `"nitpicker"` key
7
+ * in `package.json`.
8
+ */
9
+ const MODULE_NAME = 'nitpicker';
10
+ /**
11
+ * Loads the analyze plugin configuration from the user's config file.
12
+ *
13
+ * Uses cosmiconfig to search the filesystem from `process.cwd()` upward
14
+ * for a Nitpicker configuration file. The external config format
15
+ * (`ConfigJSON` from `@nitpicker/types`) is normalized into the internal
16
+ * {@link Config} model:
17
+ *
18
+ * - `config.plugins.analyze` (object keyed by module name with settings)
19
+ * is converted to an ordered `Plugin[]` array
20
+ * - Boolean `true` settings are normalized to empty objects `{}`
21
+ * - The config file path is attached to each plugin for relative path resolution
22
+ * @param defaultConfig - Optional partial config to merge as defaults.
23
+ * Plugin lists are concatenated (defaults first, then discovered plugins).
24
+ * @returns Fully resolved {@link Config} with the `analyze` plugin list.
25
+ * @example
26
+ * ```ts
27
+ * // .nitpickerrc.json
28
+ * // {
29
+ * // "plugins": {
30
+ * // "analyze": {
31
+ * // "@nitpicker/analyze-axe": { "lang": "ja" },
32
+ * // "@nitpicker/analyze-markuplint": true
33
+ * // }
34
+ * // }
35
+ * // }
36
+ *
37
+ * const config = await loadPluginSettings();
38
+ * // config.analyze = [
39
+ * // { name: '@nitpicker/analyze-axe', module: '@nitpicker/analyze-axe',
40
+ * // configFilePath: '/path/to/.nitpickerrc.json', settings: { lang: 'ja' } },
41
+ * // { name: '@nitpicker/analyze-markuplint', module: '@nitpicker/analyze-markuplint',
42
+ * // configFilePath: '/path/to/.nitpickerrc.json', settings: {} },
43
+ * // ]
44
+ * ```
45
+ * @see {@link ./types.ts!Config} for the output type
46
+ * @see {@link ./types.ts!Plugin} for individual plugin entries
47
+ */
48
+ export async function loadPluginSettings(defaultConfig = {}) {
49
+ const explorer = cosmiconfig(MODULE_NAME);
50
+ const result = await explorer.search();
51
+ if (!result) {
52
+ const defaultPlugins = defaultConfig.analyze || [];
53
+ return {
54
+ analyze: defaultPlugins.length > 0 ? defaultPlugins : discoverAnalyzePlugins(),
55
+ };
56
+ }
57
+ const config = result.config;
58
+ const { isEmpty, filepath } = result;
59
+ if (!config || isEmpty) {
60
+ const defaultPlugins = defaultConfig.analyze || [];
61
+ return {
62
+ analyze: defaultPlugins.length > 0 ? defaultPlugins : discoverAnalyzePlugins(),
63
+ };
64
+ }
65
+ const analyzePlugins = [];
66
+ if (config.plugins && config.plugins.analyze) {
67
+ const moduleNames = Object.keys(config.plugins.analyze);
68
+ for (const name of moduleNames) {
69
+ if (!config.plugins.analyze[name]) {
70
+ continue;
71
+ }
72
+ const settings = config.plugins.analyze[name] === true ? {} : config.plugins.analyze[name];
73
+ analyzePlugins.push({
74
+ name,
75
+ module: name,
76
+ configFilePath: filepath,
77
+ settings,
78
+ });
79
+ }
80
+ }
81
+ const mergedPlugins = [...(defaultConfig.analyze || []), ...analyzePlugins];
82
+ return {
83
+ analyze: mergedPlugins.length > 0 ? mergedPlugins : discoverAnalyzePlugins(),
84
+ };
85
+ }
@@ -0,0 +1,127 @@
1
+ import type { AnalyzeOptions, Config, NitpickerEvent } from './types.js';
2
+ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
3
+ import { Archive } from '@nitpicker/crawler';
4
+ export { definePlugin } from './hooks/define-plugin.js';
5
+ /**
6
+ * Core orchestrator for running analyze plugins against a `.nitpicker` archive.
7
+ *
8
+ * Nitpicker opens an existing archive (produced by the crawler), loads the
9
+ * user's plugin configuration via cosmiconfig, then runs each plugin against
10
+ * every page in the archive. Results are stored back into the archive as
11
+ * `analysis/report`, `analysis/table`, and `analysis/violations`.
12
+ *
13
+ * ## Architecture decisions
14
+ *
15
+ * - **Worker threads for `eachPage`**: DOM-heavy analysis (JSDOM + axe-core,
16
+ * markuplint, etc.) runs in isolated Worker threads so that a crashing plugin
17
+ * cannot take down the main process and memory from JSDOM windows is reclaimed
18
+ * when the Worker exits. See {@link ./worker/run-in-worker.ts!runInWorker}.
19
+ *
20
+ * - **Plugin-outer, page-inner loop**: Plugins are processed sequentially,
21
+ * and for each plugin, pages are processed in parallel (limit: 50) using
22
+ * a bounded Promise pool. This enables per-plugin progress tracking via
23
+ * {@link https://www.npmjs.com/package/@d-zero/dealer | Lanes}.
24
+ *
25
+ * - **Cache layer**: Results are cached per `pluginName:url` using
26
+ * `@d-zero/shared/cache` so that re-running analysis after a partial failure
27
+ * skips already-processed pages. The cache is cleared at the start of each run.
28
+ * @example
29
+ * ```ts
30
+ * import { Nitpicker } from '@nitpicker/core';
31
+ *
32
+ * // Open an existing archive
33
+ * const nitpicker = await Nitpicker.open('./example.nitpicker');
34
+ *
35
+ * // Run all configured analyze plugins
36
+ * await nitpicker.analyze();
37
+ *
38
+ * // Or run only specific plugins by name
39
+ * await nitpicker.analyze(['@nitpicker/analyze-axe']);
40
+ *
41
+ * // Write updated archive back to disk
42
+ * await nitpicker.write();
43
+ * ```
44
+ * @see {@link ./types.ts!NitpickerEvent} for emitted events
45
+ * @see {@link ./types.ts!Config} for the resolved configuration model
46
+ */
47
+ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
48
+ #private;
49
+ /** The underlying archive instance. */
50
+ get archive(): Archive;
51
+ /**
52
+ * @param archive - An opened {@link Archive} instance to analyze.
53
+ * Use {@link Nitpicker.open} for a convenient static factory.
54
+ */
55
+ constructor(archive: Archive);
56
+ /**
57
+ * Runs all configured analyze plugins (or a filtered subset) against
58
+ * every page in the archive.
59
+ *
60
+ * Plugins are processed **sequentially** (one at a time), while pages
61
+ * within each plugin are processed in **parallel** (bounded to
62
+ * {@link CONCURRENCY_LIMIT}). This architecture enables per-plugin
63
+ * progress tracking via Lanes.
64
+ *
65
+ * The analysis proceeds in two phases:
66
+ *
67
+ * 1. **`eachPage` phase** - For each plugin with `eachPage`, spawns
68
+ * Worker threads via a bounded Promise pool to analyze all pages.
69
+ * Progress is displayed via Lanes if provided in options.
70
+ *
71
+ * 2. **`eachUrl` phase** - For all plugins with `eachUrl`, runs
72
+ * sequentially in the main thread. These are lightweight checks
73
+ * that don't need DOM access.
74
+ *
75
+ * On completion, three data entries are stored in the archive:
76
+ * - `analysis/report` - Full {@link Report} with headers, data, and violations
77
+ * - `analysis/table` - The raw {@link Table} instance (serialized)
78
+ * - `analysis/violations` - Flat array of all {@link Violation} records
79
+ * @param filter - Optional list of plugin module names to run.
80
+ * If omitted, all configured plugins are executed.
81
+ * @param options - Optional settings for progress display.
82
+ * @example
83
+ * ```ts
84
+ * // Run all plugins
85
+ * await nitpicker.analyze();
86
+ *
87
+ * // Run only axe and markuplint with Lanes progress
88
+ * await nitpicker.analyze(
89
+ * ['@nitpicker/analyze-axe', '@nitpicker/analyze-markuplint'],
90
+ * { lanes },
91
+ * );
92
+ * ```
93
+ */
94
+ analyze(filter?: string[], options?: AnalyzeOptions): Promise<void>;
95
+ /**
96
+ * Loads and caches the plugin configuration from the user's config file.
97
+ *
98
+ * Uses cosmiconfig to search for `.nitpickerrc`, `.nitpickerrc.json`,
99
+ * `nitpicker.config.js`, or a `nitpicker` key in `package.json`.
100
+ * The result is cached after the first call.
101
+ * @returns Resolved {@link Config} with the `analyze` plugin list.
102
+ */
103
+ getConfig(): Promise<Config>;
104
+ /**
105
+ * Writes the archive (including any new analysis results) to disk
106
+ * as a `.nitpicker` tar file, then emits a `writeFile` event.
107
+ * @fires NitpickerEvent#writeFile
108
+ */
109
+ write(): Promise<void>;
110
+ /**
111
+ * Opens an existing `.nitpicker` archive file and returns a ready-to-use
112
+ * Nitpicker instance.
113
+ *
114
+ * This is the recommended way to create a Nitpicker instance. It extracts
115
+ * the archive to a temporary directory, opens the SQLite database, and
116
+ * enables plugin data access.
117
+ * @param filePath - Path to the `.nitpicker` archive file.
118
+ * @returns A new Nitpicker instance backed by the opened archive.
119
+ * @example
120
+ * ```ts
121
+ * const nitpicker = await Nitpicker.open('./site.nitpicker');
122
+ * await nitpicker.analyze();
123
+ * await nitpicker.write();
124
+ * ```
125
+ */
126
+ static open(filePath: string): Promise<Nitpicker>;
127
+ }
@@ -0,0 +1,338 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { Cache } from '@d-zero/shared/cache';
4
+ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
5
+ import { Archive } from '@nitpicker/crawler';
6
+ import c from 'ansi-colors';
7
+ import { importModules } from './import-modules.js';
8
+ import { loadPluginSettings } from './load-plugin-settings.js';
9
+ import { Table } from './table.js';
10
+ import { UrlEventBus } from './url-event-bus.js';
11
+ import { runInWorker } from './worker/run-in-worker.js';
12
+ export { definePlugin } from './hooks/define-plugin.js';
13
+ const __filename = new URL(import.meta.url).pathname;
14
+ const __dirname = path.dirname(__filename);
15
+ /**
16
+ * Resolved path to the compiled Worker entry point.
17
+ * This file is loaded by `runInWorker()` in a `new Worker(...)` call.
18
+ * @see {@link ./page-analysis-worker.ts} for the source
19
+ */
20
+ const pageAnalysisWorkerPath = path.resolve(__dirname, 'page-analysis-worker.js');
21
+ /** Maximum number of concurrent Worker threads per plugin. */
22
+ const CONCURRENCY_LIMIT = 50;
23
+ /**
24
+ * Core orchestrator for running analyze plugins against a `.nitpicker` archive.
25
+ *
26
+ * Nitpicker opens an existing archive (produced by the crawler), loads the
27
+ * user's plugin configuration via cosmiconfig, then runs each plugin against
28
+ * every page in the archive. Results are stored back into the archive as
29
+ * `analysis/report`, `analysis/table`, and `analysis/violations`.
30
+ *
31
+ * ## Architecture decisions
32
+ *
33
+ * - **Worker threads for `eachPage`**: DOM-heavy analysis (JSDOM + axe-core,
34
+ * markuplint, etc.) runs in isolated Worker threads so that a crashing plugin
35
+ * cannot take down the main process and memory from JSDOM windows is reclaimed
36
+ * when the Worker exits. See {@link ./worker/run-in-worker.ts!runInWorker}.
37
+ *
38
+ * - **Plugin-outer, page-inner loop**: Plugins are processed sequentially,
39
+ * and for each plugin, pages are processed in parallel (limit: 50) using
40
+ * a bounded Promise pool. This enables per-plugin progress tracking via
41
+ * {@link https://www.npmjs.com/package/@d-zero/dealer | Lanes}.
42
+ *
43
+ * - **Cache layer**: Results are cached per `pluginName:url` using
44
+ * `@d-zero/shared/cache` so that re-running analysis after a partial failure
45
+ * skips already-processed pages. The cache is cleared at the start of each run.
46
+ * @example
47
+ * ```ts
48
+ * import { Nitpicker } from '@nitpicker/core';
49
+ *
50
+ * // Open an existing archive
51
+ * const nitpicker = await Nitpicker.open('./example.nitpicker');
52
+ *
53
+ * // Run all configured analyze plugins
54
+ * await nitpicker.analyze();
55
+ *
56
+ * // Or run only specific plugins by name
57
+ * await nitpicker.analyze(['@nitpicker/analyze-axe']);
58
+ *
59
+ * // Write updated archive back to disk
60
+ * await nitpicker.write();
61
+ * ```
62
+ * @see {@link ./types.ts!NitpickerEvent} for emitted events
63
+ * @see {@link ./types.ts!Config} for the resolved configuration model
64
+ */
65
+ export class Nitpicker extends EventEmitter {
66
+ /**
67
+ * The underlying archive instance providing access to the SQLite database
68
+ * and file storage. Injected via constructor or created by `Nitpicker.open()`.
69
+ */
70
+ #archive;
71
+ /**
72
+ * Lazily loaded and cached plugin configuration.
73
+ * `null` until `getConfig()` is first called.
74
+ */
75
+ #config = null;
76
+ /** The underlying archive instance. */
77
+ get archive() {
78
+ return this.#archive;
79
+ }
80
+ /**
81
+ * @param archive - An opened {@link Archive} instance to analyze.
82
+ * Use {@link Nitpicker.open} for a convenient static factory.
83
+ */
84
+ constructor(archive) {
85
+ super();
86
+ this.#archive = archive;
87
+ }
88
+ /**
89
+ * Runs all configured analyze plugins (or a filtered subset) against
90
+ * every page in the archive.
91
+ *
92
+ * Plugins are processed **sequentially** (one at a time), while pages
93
+ * within each plugin are processed in **parallel** (bounded to
94
+ * {@link CONCURRENCY_LIMIT}). This architecture enables per-plugin
95
+ * progress tracking via Lanes.
96
+ *
97
+ * The analysis proceeds in two phases:
98
+ *
99
+ * 1. **`eachPage` phase** - For each plugin with `eachPage`, spawns
100
+ * Worker threads via a bounded Promise pool to analyze all pages.
101
+ * Progress is displayed via Lanes if provided in options.
102
+ *
103
+ * 2. **`eachUrl` phase** - For all plugins with `eachUrl`, runs
104
+ * sequentially in the main thread. These are lightweight checks
105
+ * that don't need DOM access.
106
+ *
107
+ * On completion, three data entries are stored in the archive:
108
+ * - `analysis/report` - Full {@link Report} with headers, data, and violations
109
+ * - `analysis/table` - The raw {@link Table} instance (serialized)
110
+ * - `analysis/violations` - Flat array of all {@link Violation} records
111
+ * @param filter - Optional list of plugin module names to run.
112
+ * If omitted, all configured plugins are executed.
113
+ * @param options - Optional settings for progress display.
114
+ * @example
115
+ * ```ts
116
+ * // Run all plugins
117
+ * await nitpicker.analyze();
118
+ *
119
+ * // Run only axe and markuplint with Lanes progress
120
+ * await nitpicker.analyze(
121
+ * ['@nitpicker/analyze-axe', '@nitpicker/analyze-markuplint'],
122
+ * { lanes },
123
+ * );
124
+ * ```
125
+ */
126
+ async analyze(filter, options) {
127
+ const config = await this.getConfig();
128
+ const plugins = filter
129
+ ? config.analyze.filter((plugin) => filter?.includes(plugin.name))
130
+ : config.analyze;
131
+ const analyzeMods = await importModules(plugins);
132
+ const lanes = options?.lanes;
133
+ const table = new Table();
134
+ for (const mod of analyzeMods) {
135
+ if (!mod.headers) {
136
+ continue;
137
+ }
138
+ if (!mod.eachPage) {
139
+ continue;
140
+ }
141
+ table.addHeaders(mod.headers);
142
+ }
143
+ const allViolations = [];
144
+ const cache = new Cache('nitpicker-axe', path.join(os.tmpdir(), 'nitpicker/cache/table'));
145
+ await cache.clear();
146
+ // Build plugin metadata: lane IDs and display labels
147
+ const eachPagePlugins = [];
148
+ for (const [i, plugin] of plugins.entries()) {
149
+ if (analyzeMods[i]?.eachPage) {
150
+ eachPagePlugins.push({ plugin, modIndex: i });
151
+ }
152
+ }
153
+ const pluginLaneIds = new Map();
154
+ const pluginLabels = new Map();
155
+ const pluginCompletionDetails = new Map();
156
+ for (const [laneId, { plugin, modIndex }] of eachPagePlugins.entries()) {
157
+ pluginLaneIds.set(plugin.name, laneId);
158
+ pluginLabels.set(plugin.name, analyzeMods[modIndex]?.label ?? plugin.name);
159
+ }
160
+ // Initialize all lanes as Waiting
161
+ for (const [name, id] of pluginLaneIds) {
162
+ const label = pluginLabels.get(name) ?? name;
163
+ lanes?.update(id, c.dim(`${label}: Waiting...`));
164
+ }
165
+ await this.archive.getPagesWithRefs(100_000, async (pages) => {
166
+ const urlEmitter = new UrlEventBus();
167
+ // Phase 1: eachPage plugins (sequentially, pages in parallel)
168
+ for (const [pluginSeqIndex, { plugin }] of eachPagePlugins.entries()) {
169
+ const laneId = pluginLaneIds.get(plugin.name);
170
+ const label = pluginLabels.get(plugin.name) ?? plugin.name;
171
+ let done = 0;
172
+ let pluginViolationCount = 0;
173
+ const updateProgress = () => {
174
+ const pluginPercent = Math.round((done / pages.length) * 100);
175
+ const overallPercent = Math.round(((pluginSeqIndex + done / pages.length) / eachPagePlugins.length) * 100);
176
+ lanes?.header(`[${pluginSeqIndex + 1}/${eachPagePlugins.length}] Analyzing (${overallPercent}%)`);
177
+ lanes?.update(laneId, `${label}: ${done}/${pages.length} (${pluginPercent}%)%braille%`);
178
+ };
179
+ updateProgress();
180
+ // Bounded Promise pool (replaces deal())
181
+ const executing = new Set();
182
+ for (const [pageIndex, page] of pages.entries()) {
183
+ const task = (async () => {
184
+ const cacheKey = `${plugin.name}:${page.url.href}`;
185
+ const cached = await cache.load(cacheKey);
186
+ if (cached) {
187
+ const { pages: cachedPages, violations } = cached;
188
+ if (cachedPages) {
189
+ table.addData(cachedPages);
190
+ }
191
+ if (violations) {
192
+ allViolations.push(...violations);
193
+ pluginViolationCount += violations.length;
194
+ }
195
+ done++;
196
+ updateProgress();
197
+ return;
198
+ }
199
+ const html = await page.getHtml();
200
+ if (!html) {
201
+ done++;
202
+ updateProgress();
203
+ return;
204
+ }
205
+ const report = await runInWorker({
206
+ filePath: pageAnalysisWorkerPath,
207
+ num: pageIndex,
208
+ total: pages.length,
209
+ emitter: urlEmitter,
210
+ initialData: {
211
+ plugin,
212
+ pages: {
213
+ html,
214
+ url: page.url,
215
+ },
216
+ },
217
+ });
218
+ const tablePages = {};
219
+ if (report?.page) {
220
+ tablePages[page.url.href] = report.page;
221
+ table.addDataToUrl(page.url, report.page);
222
+ }
223
+ await cache.store(cacheKey, {
224
+ pages: Object.keys(tablePages).length > 0 ? tablePages : undefined,
225
+ violations: report?.violations,
226
+ });
227
+ if (report?.violations) {
228
+ allViolations.push(...report.violations);
229
+ pluginViolationCount += report.violations.length;
230
+ }
231
+ done++;
232
+ updateProgress();
233
+ })();
234
+ executing.add(task);
235
+ task.then(() => executing.delete(task), () => executing.delete(task));
236
+ if (executing.size >= CONCURRENCY_LIMIT) {
237
+ await Promise.race(executing);
238
+ }
239
+ }
240
+ await Promise.all(executing);
241
+ // Mark this plugin as Done
242
+ const detail = pluginViolationCount > 0
243
+ ? `${pluginViolationCount} violations`
244
+ : `${done} pages`;
245
+ pluginCompletionDetails.set(plugin.name, detail);
246
+ lanes?.update(laneId, c.green(`${label}: Done (${detail})`));
247
+ // Dim inactive lanes
248
+ for (const [name, id] of pluginLaneIds) {
249
+ if (name === plugin.name) {
250
+ continue;
251
+ }
252
+ const otherLabel = pluginLabels.get(name) ?? name;
253
+ const completionDetail = pluginCompletionDetails.get(name);
254
+ if (completionDetail) {
255
+ lanes?.update(id, c.dim(`${otherLabel}: Done (${completionDetail})`));
256
+ }
257
+ else {
258
+ lanes?.update(id, c.dim(`${otherLabel}: Waiting...`));
259
+ }
260
+ }
261
+ }
262
+ // Phase 2: eachUrl plugins (main thread, sequential)
263
+ for (const page of pages) {
264
+ const url = page.url;
265
+ const isExternal = page.isExternal;
266
+ for (const mod of analyzeMods) {
267
+ if (!mod.eachUrl) {
268
+ continue;
269
+ }
270
+ const report = await mod.eachUrl({ url, isExternal });
271
+ if (!report) {
272
+ continue;
273
+ }
274
+ const { page: reportPage, violations } = report;
275
+ if (reportPage) {
276
+ table.addDataToUrl(url, reportPage);
277
+ }
278
+ if (violations) {
279
+ allViolations.push(...violations);
280
+ }
281
+ }
282
+ }
283
+ }, {
284
+ withRefs: false,
285
+ });
286
+ const report = {
287
+ name: 'general',
288
+ pageData: table.toJSON(),
289
+ violations: allViolations,
290
+ };
291
+ await this.archive.setData('analysis/report', report);
292
+ await this.archive.setData('analysis/table', table);
293
+ await this.archive.setData('analysis/violations', allViolations);
294
+ }
295
+ /**
296
+ * Loads and caches the plugin configuration from the user's config file.
297
+ *
298
+ * Uses cosmiconfig to search for `.nitpickerrc`, `.nitpickerrc.json`,
299
+ * `nitpicker.config.js`, or a `nitpicker` key in `package.json`.
300
+ * The result is cached after the first call.
301
+ * @returns Resolved {@link Config} with the `analyze` plugin list.
302
+ */
303
+ async getConfig() {
304
+ if (!this.#config) {
305
+ this.#config = await loadPluginSettings();
306
+ }
307
+ return this.#config;
308
+ }
309
+ /**
310
+ * Writes the archive (including any new analysis results) to disk
311
+ * as a `.nitpicker` tar file, then emits a `writeFile` event.
312
+ * @fires NitpickerEvent#writeFile
313
+ */
314
+ async write() {
315
+ await this.#archive.write();
316
+ await this.emit('writeFile', { filePath: this.#archive.filePath });
317
+ }
318
+ /**
319
+ * Opens an existing `.nitpicker` archive file and returns a ready-to-use
320
+ * Nitpicker instance.
321
+ *
322
+ * This is the recommended way to create a Nitpicker instance. It extracts
323
+ * the archive to a temporary directory, opens the SQLite database, and
324
+ * enables plugin data access.
325
+ * @param filePath - Path to the `.nitpicker` archive file.
326
+ * @returns A new Nitpicker instance backed by the opened archive.
327
+ * @example
328
+ * ```ts
329
+ * const nitpicker = await Nitpicker.open('./site.nitpicker');
330
+ * await nitpicker.analyze();
331
+ * await nitpicker.write();
332
+ * ```
333
+ */
334
+ static async open(filePath) {
335
+ const archive = await Archive.open({ filePath, openPluginData: true });
336
+ return new Nitpicker(archive);
337
+ }
338
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Worker thread module for per-page single-plugin execution.
3
+ *
4
+ * This is the default export loaded by {@link ./worker/runner.ts!runner}
5
+ * when analyzing a single page with a single plugin. It:
6
+ *
7
+ * 1. Dynamically imports the configured plugin via {@link importModules}
8
+ * 2. If the plugin implements `eachPage`:
9
+ * - Creates a JSDOM instance from the raw HTML
10
+ * - Calls the plugin's `eachPage` hook with the DOM window
11
+ * - Closes the JSDOM window to free memory
12
+ * 3. Returns the plugin result as {@link ReportPage} or `null`
13
+ *
14
+ * Each Worker invocation handles exactly one plugin, so the calling code
15
+ * can track per-plugin progress independently.
16
+ * @module
17
+ */
18
+ import type { Plugin, ReportPage } from './types.js';
19
+ import type { UrlEventBus } from './url-event-bus.js';
20
+ import type { ExURL as URL } from '@d-zero/shared/parse-url';
21
+ /**
22
+ * Initial data payload for the page analysis worker.
23
+ * Passed via `workerData` and consumed by the default export.
24
+ *
25
+ * Uses `type` instead of `interface` because this type must satisfy the
26
+ * `Record<string, unknown>` constraint required by the Worker data serialization.
27
+ */
28
+ export type PageAnalysisWorkerData = {
29
+ /** Single analyze plugin to execute against the page. */
30
+ plugin: Plugin;
31
+ /** Page data containing raw HTML and parsed URL. */
32
+ pages: {
33
+ html: string;
34
+ url: URL;
35
+ };
36
+ };
37
+ /**
38
+ * Executes a single plugin's `eachPage` hook against a single page.
39
+ * @template T - Column key union from the plugin's headers.
40
+ * @param data - Contains the single plugin and page data (HTML + URL).
41
+ * @param urlEventBus - Event bus for URL discovery events (forwarded to the main thread).
42
+ * @param num - Zero-based index of the current page in the batch.
43
+ * @param total - Total number of pages being processed.
44
+ * @returns Report data from the plugin, or `null` if skipped.
45
+ * @see {@link ./worker/runner.ts!runner} for how this function is called
46
+ * @see {@link ./nitpicker.ts!Nitpicker.analyze} for the orchestration context
47
+ */
48
+ export default function <T extends string>(data: PageAnalysisWorkerData, urlEventBus: UrlEventBus, num: number, total: number): Promise<ReportPage<T> | null>;