@nitpicker/core 0.4.2 → 0.4.4

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