@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.
- package/CHANGELOG.md +8 -0
- package/LICENSE +191 -0
- package/README.md +13 -0
- package/lib/discover-analyze-plugins.d.ts +14 -0
- package/lib/discover-analyze-plugins.js +34 -0
- package/lib/find-nitpicker-modules-dir.d.ts +12 -0
- package/lib/find-nitpicker-modules-dir.js +23 -0
- package/lib/hooks/actions.d.ts +9 -0
- package/lib/hooks/actions.js +9 -0
- package/lib/hooks/child-process.d.ts +1 -0
- package/lib/hooks/child-process.js +34 -0
- package/lib/hooks/define-plugin.d.ts +68 -0
- package/lib/hooks/define-plugin.js +69 -0
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/runner.d.ts +10 -0
- package/lib/hooks/runner.js +32 -0
- package/lib/import-modules.d.ts +24 -0
- package/lib/import-modules.js +38 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/load-plugin-settings.d.ts +40 -0
- package/lib/load-plugin-settings.js +85 -0
- package/lib/nitpicker.d.ts +127 -0
- package/lib/nitpicker.js +338 -0
- package/lib/page-analysis-worker.d.ts +48 -0
- package/lib/page-analysis-worker.js +98 -0
- package/lib/read-plugin-labels.d.ts +15 -0
- package/lib/read-plugin-labels.js +30 -0
- package/lib/table.d.ts +75 -0
- package/lib/table.js +132 -0
- package/lib/types.d.ts +264 -0
- package/lib/types.js +1 -0
- package/lib/url-event-bus.d.ts +32 -0
- package/lib/url-event-bus.js +20 -0
- package/lib/utils.d.ts +36 -0
- package/lib/utils.js +43 -0
- package/lib/worker/run-in-worker.d.ts +51 -0
- package/lib/worker/run-in-worker.js +120 -0
- package/lib/worker/runner.d.ts +25 -0
- package/lib/worker/runner.js +31 -0
- package/lib/worker/types.d.ts +23 -0
- package/lib/worker/types.js +1 -0
- package/lib/worker/worker.d.ts +27 -0
- package/lib/worker/worker.js +53 -0
- package/package.json +36 -0
- package/src/discover-analyze-plugins.spec.ts +21 -0
- package/src/discover-analyze-plugins.ts +37 -0
- package/src/hooks/define-plugin.spec.ts +38 -0
- package/src/hooks/define-plugin.ts +73 -0
- package/src/hooks/index.ts +1 -0
- package/src/import-modules.spec.ts +150 -0
- package/src/import-modules.ts +45 -0
- package/src/index.ts +5 -0
- package/src/load-plugin-settings.spec.ts +192 -0
- package/src/load-plugin-settings.ts +99 -0
- package/src/nitpicker.ts +418 -0
- package/src/page-analysis-worker.spec.ts +287 -0
- package/src/page-analysis-worker.ts +131 -0
- package/src/read-plugin-labels.spec.ts +151 -0
- package/src/read-plugin-labels.ts +37 -0
- package/src/table.spec.ts +83 -0
- package/src/table.ts +149 -0
- package/src/types.ts +289 -0
- package/src/url-event-bus.spec.ts +28 -0
- package/src/url-event-bus.ts +33 -0
- package/src/worker/run-in-worker.ts +155 -0
- package/src/worker/runner.ts +38 -0
- package/src/worker/types.ts +25 -0
- package/src/worker/worker.ts +64 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
|
|
19
|
+
import type { Plugin, ReportPage } from './types.js';
|
|
20
|
+
import type { UrlEventBus } from './url-event-bus.js';
|
|
21
|
+
import type { ExURL as URL } from '@d-zero/shared/parse-url';
|
|
22
|
+
|
|
23
|
+
import { JSDOM } from 'jsdom';
|
|
24
|
+
|
|
25
|
+
import { importModules } from './import-modules.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Set of critical Node.js global properties that must never be overwritten
|
|
29
|
+
* by JSDOM window properties.
|
|
30
|
+
*/
|
|
31
|
+
const PROTECTED_GLOBALS = new Set([
|
|
32
|
+
'process',
|
|
33
|
+
'global',
|
|
34
|
+
'globalThis',
|
|
35
|
+
'console',
|
|
36
|
+
'Buffer',
|
|
37
|
+
'setTimeout',
|
|
38
|
+
'setInterval',
|
|
39
|
+
'clearTimeout',
|
|
40
|
+
'clearInterval',
|
|
41
|
+
'setImmediate',
|
|
42
|
+
'clearImmediate',
|
|
43
|
+
'queueMicrotask',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initial data payload for the page analysis worker.
|
|
48
|
+
* Passed via `workerData` and consumed by the default export.
|
|
49
|
+
*
|
|
50
|
+
* Uses `type` instead of `interface` because this type must satisfy the
|
|
51
|
+
* `Record<string, unknown>` constraint required by the Worker data serialization.
|
|
52
|
+
*/
|
|
53
|
+
export type PageAnalysisWorkerData = {
|
|
54
|
+
/** Single analyze plugin to execute against the page. */
|
|
55
|
+
plugin: Plugin;
|
|
56
|
+
/** Page data containing raw HTML and parsed URL. */
|
|
57
|
+
pages: { html: string; url: URL };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Executes a single plugin's `eachPage` hook against a single page.
|
|
62
|
+
* @template T - Column key union from the plugin's headers.
|
|
63
|
+
* @param data - Contains the single plugin and page data (HTML + URL).
|
|
64
|
+
* @param urlEventBus - Event bus for URL discovery events (forwarded to the main thread).
|
|
65
|
+
* @param num - Zero-based index of the current page in the batch.
|
|
66
|
+
* @param total - Total number of pages being processed.
|
|
67
|
+
* @returns Report data from the plugin, or `null` if skipped.
|
|
68
|
+
* @see {@link ./worker/runner.ts!runner} for how this function is called
|
|
69
|
+
* @see {@link ./nitpicker.ts!Nitpicker.analyze} for the orchestration context
|
|
70
|
+
*/
|
|
71
|
+
export default async function <T extends string>(
|
|
72
|
+
data: PageAnalysisWorkerData,
|
|
73
|
+
urlEventBus: UrlEventBus,
|
|
74
|
+
num: number,
|
|
75
|
+
total: number,
|
|
76
|
+
): Promise<ReportPage<T> | null> {
|
|
77
|
+
const {
|
|
78
|
+
plugin,
|
|
79
|
+
pages: { html, url },
|
|
80
|
+
} = data;
|
|
81
|
+
const [analyzeMod] = await importModules([plugin]);
|
|
82
|
+
|
|
83
|
+
if (!analyzeMod?.eachPage) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await urlEventBus.emit('url', url.href);
|
|
88
|
+
|
|
89
|
+
const dom = new JSDOM(html, {
|
|
90
|
+
url: url.href,
|
|
91
|
+
runScripts: 'outside-only',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Expose JSDOM globals so that browser-oriented libraries
|
|
95
|
+
// (axe-core, @medv/finder, etc.) that inspect the global scope
|
|
96
|
+
// can find `window`, `document`, `Node`, and other DOM APIs.
|
|
97
|
+
const g = globalThis as Record<string, unknown>;
|
|
98
|
+
const domGlobalKeys: string[] = [];
|
|
99
|
+
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
|
100
|
+
if (key in g || PROTECTED_GLOBALS.has(key)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
g[key] = dom.window[key as keyof typeof dom.window];
|
|
105
|
+
domGlobalKeys.push(key);
|
|
106
|
+
} catch {
|
|
107
|
+
// Some window properties throw on access — skip them
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const report = await analyzeMod.eachPage({
|
|
113
|
+
url,
|
|
114
|
+
html,
|
|
115
|
+
window: dom.window,
|
|
116
|
+
num,
|
|
117
|
+
total,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return report ?? null;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.error(`[${plugin.name}] ${error instanceof Error ? error.message : error}`);
|
|
124
|
+
return null;
|
|
125
|
+
} finally {
|
|
126
|
+
for (const key of domGlobalKeys) {
|
|
127
|
+
delete g[key];
|
|
128
|
+
}
|
|
129
|
+
dom.window.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { Plugin } from './types.js';
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
|
|
5
|
+
describe('readPluginLabels', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('reads labels from plugins', async () => {
|
|
11
|
+
vi.doMock('label-plugin-a', () => ({
|
|
12
|
+
default: () => ({ label: 'axe: アクセシビリティチェック' }),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
16
|
+
|
|
17
|
+
const plugins: Plugin[] = [
|
|
18
|
+
{
|
|
19
|
+
name: '@nitpicker/analyze-axe',
|
|
20
|
+
module: 'label-plugin-a',
|
|
21
|
+
configFilePath: '/config',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const labels = await readPluginLabels(plugins);
|
|
26
|
+
|
|
27
|
+
expect(labels.get('@nitpicker/analyze-axe')).toBe('axe: アクセシビリティチェック');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reads labels from multiple plugins', async () => {
|
|
31
|
+
vi.doMock('label-plugin-b1', () => ({
|
|
32
|
+
default: () => ({ label: 'Plugin B1' }),
|
|
33
|
+
}));
|
|
34
|
+
vi.doMock('label-plugin-b2', () => ({
|
|
35
|
+
default: () => ({ label: 'Plugin B2' }),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
39
|
+
|
|
40
|
+
const plugins: Plugin[] = [
|
|
41
|
+
{ name: 'b1', module: 'label-plugin-b1', configFilePath: '' },
|
|
42
|
+
{ name: 'b2', module: 'label-plugin-b2', configFilePath: '' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const labels = await readPluginLabels(plugins);
|
|
46
|
+
|
|
47
|
+
expect(labels.size).toBe(2);
|
|
48
|
+
expect(labels.get('b1')).toBe('Plugin B1');
|
|
49
|
+
expect(labels.get('b2')).toBe('Plugin B2');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('skips plugins that lack a label', async () => {
|
|
53
|
+
vi.doMock('label-plugin-c', () => ({
|
|
54
|
+
default: () => ({ headers: { col: 'Column' } }),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
58
|
+
|
|
59
|
+
const plugins: Plugin[] = [
|
|
60
|
+
{ name: 'no-label', module: 'label-plugin-c', configFilePath: '' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const labels = await readPluginLabels(plugins);
|
|
64
|
+
|
|
65
|
+
expect(labels.size).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips plugins whose import fails', async () => {
|
|
69
|
+
vi.doMock('label-plugin-broken', () => {
|
|
70
|
+
throw new Error('module not found');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
74
|
+
|
|
75
|
+
const plugins: Plugin[] = [
|
|
76
|
+
{ name: 'broken', module: 'label-plugin-broken', configFilePath: '' },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const labels = await readPluginLabels(plugins);
|
|
80
|
+
|
|
81
|
+
expect(labels.size).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('skips plugins whose factory throws', async () => {
|
|
85
|
+
vi.doMock('label-plugin-throw', () => ({
|
|
86
|
+
default: () => {
|
|
87
|
+
throw new Error('factory error');
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
92
|
+
|
|
93
|
+
const plugins: Plugin[] = [
|
|
94
|
+
{ name: 'throw', module: 'label-plugin-throw', configFilePath: '' },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const labels = await readPluginLabels(plugins);
|
|
98
|
+
|
|
99
|
+
expect(labels.size).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('passes settings and configFilePath to factory', async () => {
|
|
103
|
+
const factory = vi.fn().mockReturnValue({ label: 'Configured' });
|
|
104
|
+
vi.doMock('label-plugin-d', () => ({ default: factory }));
|
|
105
|
+
|
|
106
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
107
|
+
|
|
108
|
+
const plugins: Plugin[] = [
|
|
109
|
+
{
|
|
110
|
+
name: 'configured',
|
|
111
|
+
module: 'label-plugin-d',
|
|
112
|
+
configFilePath: '/path/to/.nitpickerrc',
|
|
113
|
+
settings: { lang: 'ja' },
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
await readPluginLabels(plugins);
|
|
118
|
+
|
|
119
|
+
expect(factory).toHaveBeenCalledWith({ lang: 'ja' }, '/path/to/.nitpickerrc');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns empty map for empty plugin list', async () => {
|
|
123
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
124
|
+
const labels = await readPluginLabels([]);
|
|
125
|
+
|
|
126
|
+
expect(labels.size).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('collects labels from working plugins even when some fail', async () => {
|
|
130
|
+
vi.doMock('label-plugin-good', () => ({
|
|
131
|
+
default: () => ({ label: 'Good Plugin' }),
|
|
132
|
+
}));
|
|
133
|
+
vi.doMock('label-plugin-bad', () => ({
|
|
134
|
+
default: () => {
|
|
135
|
+
throw new Error('bad');
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const { readPluginLabels } = await import('./read-plugin-labels.js');
|
|
140
|
+
|
|
141
|
+
const plugins: Plugin[] = [
|
|
142
|
+
{ name: 'good', module: 'label-plugin-good', configFilePath: '' },
|
|
143
|
+
{ name: 'bad', module: 'label-plugin-bad', configFilePath: '' },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const labels = await readPluginLabels(plugins);
|
|
147
|
+
|
|
148
|
+
expect(labels.size).toBe(1);
|
|
149
|
+
expect(labels.get('good')).toBe('Good Plugin');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Plugin } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the `label` property from each plugin by importing and
|
|
5
|
+
* initializing the plugin module.
|
|
6
|
+
*
|
|
7
|
+
* Each plugin module's default export (a `PluginFactory`) is called
|
|
8
|
+
* with the plugin's configured settings. The resulting `AnalyzePlugin`
|
|
9
|
+
* object's `label` field is collected into a Map keyed by plugin name.
|
|
10
|
+
*
|
|
11
|
+
* Plugins that fail to import or initialize, or that lack a `label`,
|
|
12
|
+
* are silently skipped.
|
|
13
|
+
* @param plugins - Array of plugin definitions from the resolved config.
|
|
14
|
+
* @returns Map from plugin name to its human-readable label.
|
|
15
|
+
*/
|
|
16
|
+
export async function readPluginLabels(
|
|
17
|
+
plugins: readonly Plugin[],
|
|
18
|
+
): Promise<Map<string, string>> {
|
|
19
|
+
const labels = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
await Promise.all(
|
|
22
|
+
plugins.map(async (plugin) => {
|
|
23
|
+
try {
|
|
24
|
+
const mod = await import(plugin.module);
|
|
25
|
+
const factory = mod.default;
|
|
26
|
+
const instance = await factory(plugin.settings ?? {}, plugin.configFilePath);
|
|
27
|
+
if (instance && typeof instance.label === 'string') {
|
|
28
|
+
labels.set(plugin.name, instance.label);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Module not importable or factory failed — skip
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return labels;
|
|
37
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { Table } from './table.js';
|
|
4
|
+
|
|
5
|
+
describe('Table', () => {
|
|
6
|
+
it('starts with empty headers and data', () => {
|
|
7
|
+
const table = new Table<'a'>();
|
|
8
|
+
const json = table.toJSON();
|
|
9
|
+
expect(json.headers).toEqual({});
|
|
10
|
+
expect(json.data).toEqual({});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('addHeaders registers column headers', () => {
|
|
14
|
+
const table = new Table<'title' | 'score'>();
|
|
15
|
+
table.addHeaders({ title: 'Page Title', score: 'Score' });
|
|
16
|
+
const json = table.toJSON();
|
|
17
|
+
expect(json.headers).toEqual({ title: 'Page Title', score: 'Score' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('addHeaders merges headers from multiple calls', () => {
|
|
21
|
+
const table = new Table<'a' | 'b'>();
|
|
22
|
+
table.addHeaders({ a: 'A' } as Record<'a' | 'b', string>);
|
|
23
|
+
table.addHeaders({ b: 'B' } as Record<'a' | 'b', string>);
|
|
24
|
+
const json = table.toJSON();
|
|
25
|
+
expect(json.headers).toEqual({ a: 'A', b: 'B' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('addHeaders overwrites duplicate keys with the later value', () => {
|
|
29
|
+
const table = new Table<'x'>();
|
|
30
|
+
table.addHeaders({ x: 'First' });
|
|
31
|
+
table.addHeaders({ x: 'Second' });
|
|
32
|
+
expect(table.toJSON().headers).toEqual({ x: 'Second' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('addDataToUrl stores data for a URL', () => {
|
|
36
|
+
const table = new Table<'col'>();
|
|
37
|
+
const url = { href: 'https://example.com/' } as { href: string };
|
|
38
|
+
table.addDataToUrl(url, { col: { value: 'hello' } });
|
|
39
|
+
const json = table.toJSON();
|
|
40
|
+
expect(json.data['https://example.com/']).toEqual({ col: { value: 'hello' } });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('addDataToUrl merges data for the same URL', () => {
|
|
44
|
+
const table = new Table<'a' | 'b'>();
|
|
45
|
+
const url = { href: 'https://example.com/' } as { href: string };
|
|
46
|
+
table.addDataToUrl(url, { a: { value: 1 } } as Record<'a' | 'b', { value: unknown }>);
|
|
47
|
+
table.addDataToUrl(url, { b: { value: 2 } } as Record<'a' | 'b', { value: unknown }>);
|
|
48
|
+
const data = table.toJSON().data['https://example.com/'];
|
|
49
|
+
expect(data).toEqual({ a: { value: 1 }, b: { value: 2 } });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('addData stores batch data', () => {
|
|
53
|
+
const table = new Table<'col'>();
|
|
54
|
+
table.addData({
|
|
55
|
+
'https://a.com/': { col: { value: 'A' } },
|
|
56
|
+
'https://b.com/': { col: { value: 'B' } },
|
|
57
|
+
});
|
|
58
|
+
const json = table.toJSON();
|
|
59
|
+
expect(Object.keys(json.data)).toHaveLength(2);
|
|
60
|
+
expect(json.data['https://a.com/']).toEqual({ col: { value: 'A' } });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('getData returns data as a plain object', () => {
|
|
64
|
+
const table = new Table<'col'>();
|
|
65
|
+
const url = { href: 'https://example.com/' } as { href: string };
|
|
66
|
+
table.addDataToUrl(url, { col: { value: 42 } });
|
|
67
|
+
const data = table.getData();
|
|
68
|
+
expect(data['https://example.com/']).toEqual({ col: { value: 42 } });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('getDataByUrl returns data for a known URL', () => {
|
|
72
|
+
const table = new Table<'col'>();
|
|
73
|
+
const url = { href: 'https://example.com/' } as { href: string };
|
|
74
|
+
table.addDataToUrl(url, { col: { value: 'x' } });
|
|
75
|
+
expect(table.getDataByUrl(url)).toEqual({ col: { value: 'x' } });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('getDataByUrl returns undefined for unknown URL', () => {
|
|
79
|
+
const table = new Table<'col'>();
|
|
80
|
+
const url = { href: 'https://unknown.com/' } as { href: string };
|
|
81
|
+
expect(table.getDataByUrl(url)).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TableData,
|
|
3
|
+
TableHeaderMap,
|
|
4
|
+
TableHeaders,
|
|
5
|
+
TablePages,
|
|
6
|
+
TableRow,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
import type { ExURL as URL } from '@d-zero/shared/parse-url';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* In-memory accumulator for tabular report data.
|
|
12
|
+
*
|
|
13
|
+
* Table collects column headers from plugins and per-URL row data from
|
|
14
|
+
* analysis results. It uses `Map` internally for efficient merge operations
|
|
15
|
+
* (multiple plugins contribute columns to the same URL row), then serializes
|
|
16
|
+
* to plain objects for JSON storage in the archive.
|
|
17
|
+
*
|
|
18
|
+
* ## Merge semantics
|
|
19
|
+
*
|
|
20
|
+
* When data is added for a URL that already has entries, the new columns are
|
|
21
|
+
* shallow-merged with existing ones (later values overwrite earlier ones for
|
|
22
|
+
* the same key). This allows multiple plugins to contribute different columns
|
|
23
|
+
* to the same row without conflicts, as long as they use distinct column keys.
|
|
24
|
+
* @template T - String literal union of all column keys across plugins.
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* const table = new Table<'title' | 'score'>();
|
|
28
|
+
* table.addHeaders({ title: 'Page Title', score: 'Score' });
|
|
29
|
+
* table.addDataToUrl(url, {
|
|
30
|
+
* title: { value: 'Home' },
|
|
31
|
+
* score: { value: 95 },
|
|
32
|
+
* });
|
|
33
|
+
* const json = table.toJSON();
|
|
34
|
+
* // { headers: { title: 'Page Title', score: 'Score' }, data: { 'https://...': { ... } } }
|
|
35
|
+
* ```
|
|
36
|
+
* @see {@link ./types.ts} for the underlying type aliases
|
|
37
|
+
*/
|
|
38
|
+
export class Table<T extends string> {
|
|
39
|
+
/** Per-URL row data. Key is the URL href string. */
|
|
40
|
+
#data: TableRow<T> = new Map();
|
|
41
|
+
|
|
42
|
+
/** Column header definitions accumulated from all plugins. */
|
|
43
|
+
#headers: TableHeaderMap<T> = new Map();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Merges a batch of URL-keyed page data into the table.
|
|
47
|
+
* Typically called with deserialized data from a Worker thread or cache.
|
|
48
|
+
* @param data - Object where keys are URL strings and values are column data.
|
|
49
|
+
*/
|
|
50
|
+
addData(data: TablePages<T>) {
|
|
51
|
+
const entries = Object.entries(data);
|
|
52
|
+
for (const [k, v] of entries) {
|
|
53
|
+
this.#add(k, v);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Adds or merges column data for a single URL.
|
|
59
|
+
* @param url - The page URL to associate the data with.
|
|
60
|
+
* @param data - Column values to store for this URL.
|
|
61
|
+
*/
|
|
62
|
+
addDataToUrl(url: URL, data: TableData<T>) {
|
|
63
|
+
this.#add(url.href, data);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Registers column headers from a plugin.
|
|
68
|
+
*
|
|
69
|
+
* Multiple plugins can call this independently; headers are merged by key.
|
|
70
|
+
* If two plugins declare the same key with different labels, the later
|
|
71
|
+
* registration wins.
|
|
72
|
+
* @param headers - Map of column keys to display labels.
|
|
73
|
+
*/
|
|
74
|
+
addHeaders(headers: TableHeaders<T>) {
|
|
75
|
+
const entries = Object.entries(headers);
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const [key, name] = entry as [T, string];
|
|
78
|
+
this.#headers.set(key, name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns all row data as a plain object (serializable to JSON).
|
|
84
|
+
* @returns URL-keyed record of column data.
|
|
85
|
+
*/
|
|
86
|
+
getData(): TablePages<T> {
|
|
87
|
+
return mapToObject(this.#data);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Retrieves column data for a specific URL, or `undefined` if not present.
|
|
92
|
+
* @param url - The page URL to look up.
|
|
93
|
+
* @returns Column data for the URL, or `undefined`.
|
|
94
|
+
*/
|
|
95
|
+
getDataByUrl(url: URL) {
|
|
96
|
+
return this.#data.get(url.href);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Serializes the entire table (headers + data) to a JSON-compatible object.
|
|
101
|
+
*
|
|
102
|
+
* Used when storing the table in the archive via `archive.setData('analysis/table', ...)`.
|
|
103
|
+
* @returns Plain object with `headers` and `data` properties.
|
|
104
|
+
*/
|
|
105
|
+
toJSON() {
|
|
106
|
+
return {
|
|
107
|
+
headers: mapToObject(this.#headers),
|
|
108
|
+
data: mapToObject(this.#data),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Internal merge-or-insert for a single URL row.
|
|
114
|
+
* If the URL already has data, the new values are shallow-merged.
|
|
115
|
+
* @param k - URL href string used as the row key.
|
|
116
|
+
* @param v - Column data to add or merge.
|
|
117
|
+
*/
|
|
118
|
+
#add(k: string, v: TableData<T>) {
|
|
119
|
+
const data = this.#data.get(k);
|
|
120
|
+
if (data) {
|
|
121
|
+
this.#data.set(k, {
|
|
122
|
+
...data,
|
|
123
|
+
...v,
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
this.#data.set(k, v);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Converts a `Map<K, V>` to a plain `Record<K, V>` object.
|
|
133
|
+
*
|
|
134
|
+
* Used internally to serialize Map-based storage into JSON-compatible
|
|
135
|
+
* objects for archive storage and Worker message passing.
|
|
136
|
+
* @template K - String key type.
|
|
137
|
+
* @template V - Value type.
|
|
138
|
+
* @param map - The Map to convert.
|
|
139
|
+
* @returns A plain object with the same key-value pairs.
|
|
140
|
+
*/
|
|
141
|
+
function mapToObject<K extends string, V>(map: Map<K, V>) {
|
|
142
|
+
const entries = map.entries();
|
|
143
|
+
const object = {} as Record<K, V>;
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const [k, v] = entry;
|
|
146
|
+
object[k] = v;
|
|
147
|
+
}
|
|
148
|
+
return object;
|
|
149
|
+
}
|