diff --git a/packages/@nitpicker/analyze-axe/src/index.spec.ts b/packages/@nitpicker/analyze-axe/src/index.spec.ts
new file mode 100644
index 0000000..5a8bd52
--- /dev/null
+++ b/packages/@nitpicker/analyze-axe/src/index.spec.ts
@@ -0,0 +1,334 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const axeRunMock = vi.fn();
+const axeConfigureMock = vi.fn();
+
+vi.mock('axe-core', () => ({
+ default: {
+ run: axeRunMock,
+ configure: axeConfigureMock,
+ },
+}));
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let pluginFactory: typeof import('./index.js').default;
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ const mod = await import('./index.js');
+ pluginFactory = mod.default;
+});
+
+describe('analyze-axe plugin', () => {
+ it('returns label', async () => {
+ axeRunMock.mockResolvedValue({ violations: [], incomplete: [] });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+
+ expect(plugin.label).toBe('axe: アクセシビリティチェック');
+ });
+
+ it('returns empty violations when axe finds no issues', async () => {
+ axeRunMock.mockResolvedValue({ violations: [], incomplete: [] });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toEqual({ violations: [] });
+ });
+
+ it('maps violations to the expected format', async () => {
+ axeRunMock.mockResolvedValue({
+ violations: [
+ {
+ id: 'image-alt',
+ impact: 'critical',
+ description: 'Images must have alternate text',
+ help: 'Ensure images have alt attributes',
+ helpUrl: 'https://dequeuniversity.com/rules/axe/image-alt',
+ nodes: [{ html: '
' }],
+ },
+ ],
+ incomplete: [],
+ });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations).toEqual([
+ {
+ validator: 'axe',
+ severity: 'critical',
+ rule: 'image-alt',
+ code: '
',
+ message:
+ 'Images must have alternate text Ensure images have alt attributes(https://dequeuniversity.com/rules/axe/image-alt)',
+ url: 'https://example.com/page',
+ },
+ ]);
+ });
+
+ it('collects both violations and incomplete results', async () => {
+ axeRunMock.mockResolvedValue({
+ violations: [
+ {
+ id: 'rule-a',
+ impact: 'serious',
+ description: 'Violation A',
+ help: '',
+ helpUrl: '',
+ nodes: [],
+ },
+ ],
+ incomplete: [
+ {
+ id: 'rule-b',
+ impact: 'moderate',
+ description: 'Incomplete B',
+ help: '',
+ helpUrl: '',
+ nodes: [],
+ },
+ ],
+ });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations).toHaveLength(2);
+ expect(result!.violations![0]!.rule).toBe('rule-b');
+ expect(result!.violations![1]!.rule).toBe('rule-a');
+ });
+
+ it('skips entries with null impact', async () => {
+ axeRunMock.mockResolvedValue({
+ violations: [
+ {
+ id: 'null-impact-rule',
+ impact: null,
+ description: 'Should be skipped',
+ },
+ {
+ id: 'valid-rule',
+ impact: 'minor',
+ description: 'Should remain',
+ help: '',
+ helpUrl: '',
+ nodes: [],
+ },
+ ],
+ incomplete: [
+ {
+ id: 'null-incomplete',
+ impact: null,
+ description: 'Also skipped',
+ },
+ ],
+ });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations).toHaveLength(1);
+ expect(result!.violations![0]!.rule).toBe('valid-rule');
+ });
+
+ it('handles axe.run() throwing an error', async () => {
+ axeRunMock.mockRejectedValue(new Error('axe crashed'));
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations).toHaveLength(1);
+ expect(result!.violations![0]!.severity).toBe('error');
+ expect(result!.violations![0]!.message).toContain('axe crashed');
+ });
+
+ it('falls back silently when locale import fails for unknown lang', async () => {
+ axeRunMock.mockResolvedValue({ violations: [], incomplete: [] });
+
+ // Use a nonexistent locale that will trigger the catch fallback
+ const plugin = await pluginFactory({ lang: 'xx-nonexistent', config: {} }, '');
+
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ // Locale import failed, so axe.configure should NOT be called
+ expect(axeConfigureMock).not.toHaveBeenCalled();
+ expect(result).toEqual({ violations: [] });
+ });
+
+ it('calls axe.configure with locale when lang resolves successfully', async () => {
+ axeRunMock.mockResolvedValue({ violations: [], incomplete: [] });
+
+ // 'ja' locale exists in axe-core/locales/
+ const plugin = await pluginFactory({ lang: 'ja', config: {} }, '');
+
+ await plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(axeConfigureMock).toHaveBeenCalledTimes(1);
+ expect(axeConfigureMock).toHaveBeenCalledWith({
+ locale: expect.objectContaining({ lang: 'ja' }),
+ });
+ });
+
+ it('does not call axe.configure when lang option is omitted', async () => {
+ axeRunMock.mockResolvedValue({ violations: [], incomplete: [] });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ await plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(axeConfigureMock).not.toHaveBeenCalled();
+ });
+
+ it('disables the color-contrast rule in axe.run()', async () => {
+ axeRunMock.mockResolvedValue({ violations: [], incomplete: [] });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ await plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(axeRunMock).toHaveBeenCalledWith({
+ rules: { 'color-contrast': { enabled: false } },
+ });
+ });
+
+ it('uses "error" severity for non-string impact values', async () => {
+ axeRunMock.mockResolvedValue({
+ violations: [
+ {
+ id: 'weird-impact',
+ impact: 42,
+ description: 'Non-string impact',
+ help: '',
+ helpUrl: '',
+ nodes: [],
+ },
+ ],
+ incomplete: [],
+ });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations![0]!.severity).toBe('error');
+ });
+
+ it('uses UNKNOWN_RULE when id is missing', async () => {
+ axeRunMock.mockResolvedValue({
+ violations: [
+ {
+ impact: 'minor',
+ description: 'No id',
+ nodes: [],
+ },
+ ],
+ incomplete: [],
+ });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations![0]!.rule).toBe('UNKNOWN_RULE');
+ });
+
+ it('joins multiple node HTML fragments with newline', async () => {
+ axeRunMock.mockResolvedValue({
+ violations: [
+ {
+ id: 'multi-node',
+ impact: 'serious',
+ description: '',
+ help: '',
+ helpUrl: '',
+ nodes: [{ html: '
A
' }, { html: 'B
' }],
+ },
+ ],
+ incomplete: [],
+ });
+
+ const plugin = await pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations![0]!.code).toBe('A
\nB
');
+ });
+});
diff --git a/packages/@nitpicker/analyze-main-contents/src/index.spec.ts b/packages/@nitpicker/analyze-main-contents/src/index.spec.ts
new file mode 100644
index 0000000..39e62f0
--- /dev/null
+++ b/packages/@nitpicker/analyze-main-contents/src/index.spec.ts
@@ -0,0 +1,295 @@
+import { JSDOM } from 'jsdom';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('@medv/finder', () => ({
+ finder: vi.fn(() => 'main'),
+}));
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let pluginFactory: typeof import('./index.js').default;
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ const mod = await import('./index.js');
+ pluginFactory = mod.default;
+});
+
+/**
+ * Creates a JSDOM window from an HTML string.
+ * @param html - HTML to parse.
+ * @returns The JSDOM window.
+ */
+function createWindow(html: string) {
+ const dom = new JSDOM(html, { url: 'https://example.com' });
+ return dom.window;
+}
+
+describe('analyze-main-contents plugin', () => {
+ it('returns label and headers', () => {
+ const plugin = pluginFactory({}, '');
+
+ expect(plugin.label).toBe('メインコンテンツ検出');
+ expect(plugin.headers).toEqual({
+ wordCount: 'Word count',
+ headings: 'Heading count',
+ images: 'Image count',
+ table: 'Table count',
+ });
+ });
+
+ it('returns zero counts when no main content element is found', () => {
+ const window = createWindow('No main here
');
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toEqual({
+ wordCount: { value: 0 },
+ headings: { value: 0 },
+ images: { value: 0 },
+ table: { value: 0 },
+ });
+ });
+
+ it('detects element and counts word length', () => {
+ const window = createWindow('こんにちは世界');
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ wordCount: { value: 7 },
+ headings: { value: 0 },
+ images: { value: 0 },
+ table: { value: 0 },
+ },
+ });
+ });
+
+ it('detects [role="main"] element', () => {
+ const window = createWindow(
+ 'Content
',
+ );
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ wordCount: { value: 7 },
+ },
+ });
+ });
+
+ it('uses custom mainContentSelector when provided', () => {
+ const window = createWindow(
+ '',
+ );
+
+ const plugin = pluginFactory({ mainContentSelector: '#page-body' }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ wordCount: { value: 10 },
+ },
+ });
+ });
+
+ it('extracts headings from main content', () => {
+ const window = createWindow(`
+
+ Title
+ Subtitle
+ Section
+
+ `);
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ headings: { value: 3 },
+ },
+ });
+ });
+
+ it('extracts images from main content', () => {
+ const window = createWindow(`
+
+
+
+
+
+ `);
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ images: { value: 3 },
+ },
+ });
+ });
+
+ it('extracts table metadata from main content', () => {
+ const window = createWindow(`
+
+
+ | A | B |
+ | Footer | |
+
+ | Merged |
+ | 1 | 2 |
+
+
+
+ `);
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ table: {
+ value: 1,
+ note: expect.stringContaining('| r | c | h | f | m |'),
+ },
+ },
+ });
+ });
+
+ it('handles whitespace-only text content as zero word count', () => {
+ const window = createWindow(' \n\t ');
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ wordCount: { value: 0 },
+ },
+ });
+ });
+
+ it('detects #content fallback selector', () => {
+ const window = createWindow(
+ 'Fallback content
',
+ );
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ wordCount: { value: 15 },
+ },
+ });
+ });
+
+ it('detects .main fallback selector', () => {
+ const window = createWindow(
+ 'Class-based main
',
+ );
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ // "Class-basedmain" = 15 characters (whitespace stripped, hyphen kept)
+ expect(result).toMatchObject({
+ page: {
+ wordCount: { value: 15 },
+ },
+ });
+ });
+
+ it('returns first matching element in DOM order when multiple selectors match', () => {
+ // querySelector with comma-separated selectors returns the first match
+ // in DOM order. Here appears before #content in the DOM.
+ const window = createWindow(`
+
+ Semantic main
+ ID content
+
+ `);
+
+ const plugin = pluginFactory({}, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ // "Semanticmain" = 12 characters (whitespace stripped)
+ expect(result).toMatchObject({
+ page: {
+ wordCount: { value: 12 },
+ },
+ });
+ });
+});
diff --git a/packages/@nitpicker/analyze-markuplint/src/index.spec.ts b/packages/@nitpicker/analyze-markuplint/src/index.spec.ts
new file mode 100644
index 0000000..30f9cff
--- /dev/null
+++ b/packages/@nitpicker/analyze-markuplint/src/index.spec.ts
@@ -0,0 +1,233 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const execMock = vi.fn();
+
+vi.mock('markuplint', () => ({
+ MLEngine: {
+ fromCode: vi.fn(() => ({
+ exec: execMock,
+ })),
+ },
+}));
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let pluginFactory: typeof import('./index.js').default;
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ const mod = await import('./index.js');
+ pluginFactory = mod.default;
+});
+
+describe('analyze-markuplint plugin', () => {
+ it('returns label and headers', () => {
+ const plugin = pluginFactory({ config: {} }, '');
+
+ expect(plugin.label).toBe('markuplint: HTMLマークアップ検証');
+ expect(plugin.headers).toEqual({ markuplint: 'markuplint' });
+ });
+
+ it('returns null when exec() returns null', async () => {
+ execMock.mockResolvedValue(null);
+
+ const plugin = pluginFactory({ config: {} }, '');
+ const result = await plugin.eachPage!({
+ url: new URL('https://example.com/page'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when exec() returns an Error', async () => {
+ execMock.mockResolvedValue(new Error('parse failure'));
+
+ const plugin = pluginFactory({ config: {} }, '');
+ const result = await plugin.eachPage!({
+ url: new URL('https://example.com/page'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('maps violations to the expected format', async () => {
+ execMock.mockResolvedValue({
+ violations: [
+ {
+ severity: 'warning',
+ ruleId: 'attr-duplication',
+ message: 'Duplicate attribute',
+ line: 5,
+ col: 10,
+ },
+ ],
+ });
+
+ const plugin = pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toEqual({
+ page: {
+ markuplint: {
+ value: '1',
+ note: 'Duplicate attribute (attr-duplication)',
+ },
+ },
+ violations: [
+ {
+ validator: 'markuplint',
+ severity: 'warning',
+ rule: 'attr-duplication',
+ code: '',
+ message: 'Duplicate attribute',
+ url: 'https://example.com/page (5:10)',
+ },
+ ],
+ });
+ });
+
+ it('returns zero violations when markuplint finds no issues', async () => {
+ execMock.mockResolvedValue({ violations: [] });
+
+ const plugin = pluginFactory({ config: {} }, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toEqual({
+ page: {
+ markuplint: { value: '0', note: '' },
+ },
+ violations: [],
+ });
+ });
+
+ it('appends index.html for URLs ending with /', async () => {
+ const { MLEngine } = await import('markuplint');
+ execMock.mockResolvedValue({ violations: [] });
+
+ const plugin = pluginFactory({ config: {} }, '');
+ await plugin.eachPage!({
+ url: new URL('https://example.com/dir/'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(MLEngine.fromCode).toHaveBeenLastCalledWith('', {
+ name: 'https://example.com/dir/index.html',
+ config: {},
+ });
+ });
+
+ it('keeps URLs already ending with .html as-is', async () => {
+ const { MLEngine } = await import('markuplint');
+ execMock.mockResolvedValue({ violations: [] });
+
+ const plugin = pluginFactory({ config: {} }, '');
+ await plugin.eachPage!({
+ url: new URL('https://example.com/page.html'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(MLEngine.fromCode).toHaveBeenLastCalledWith('', {
+ name: 'https://example.com/page.html',
+ config: {},
+ });
+ });
+
+ it('appends .html for URLs without extension', async () => {
+ const { MLEngine } = await import('markuplint');
+ execMock.mockResolvedValue({ violations: [] });
+
+ const plugin = pluginFactory({ config: {} }, '');
+ await plugin.eachPage!({
+ url: new URL('https://example.com/about'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(MLEngine.fromCode).toHaveBeenLastCalledWith('', {
+ name: 'https://example.com/about.html',
+ config: {},
+ });
+ });
+
+ it('passes config from options to MLEngine', async () => {
+ const { MLEngine } = await import('markuplint');
+ execMock.mockResolvedValue({ violations: [] });
+
+ const customConfig = { rules: { 'attr-duplication': true } };
+ const plugin = pluginFactory({ config: customConfig }, '');
+ await plugin.eachPage!({
+ url: new URL('https://example.com/'),
+ html: 'test
',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(MLEngine.fromCode).toHaveBeenLastCalledWith('test
', {
+ name: 'https://example.com/index.html',
+ config: customConfig,
+ });
+ });
+
+ it('collects multiple violations', async () => {
+ execMock.mockResolvedValue({
+ violations: [
+ {
+ severity: 'error',
+ ruleId: 'rule-a',
+ message: 'Error A',
+ line: 1,
+ col: 1,
+ },
+ {
+ severity: 'warning',
+ ruleId: 'rule-b',
+ message: 'Warning B',
+ line: 2,
+ col: 5,
+ },
+ ],
+ });
+
+ const plugin = pluginFactory({ config: {} }, '');
+ const result = await plugin.eachPage!({
+ url: new URL('https://example.com/page'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations).toHaveLength(2);
+ expect(result!.page!.markuplint.value).toBe('2');
+ });
+});
diff --git a/packages/@nitpicker/analyze-search/src/index.spec.ts b/packages/@nitpicker/analyze-search/src/index.spec.ts
new file mode 100644
index 0000000..ea6942a
--- /dev/null
+++ b/packages/@nitpicker/analyze-search/src/index.spec.ts
@@ -0,0 +1,277 @@
+import { JSDOM } from 'jsdom';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let pluginFactory: typeof import('./index.js').default;
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ const mod = await import('./index.js');
+ pluginFactory = mod.default;
+});
+
+/**
+ * Creates a JSDOM window from an HTML string and installs global `Node`
+ * so that `recursiveSearch` can reference `Node.TEXT_NODE` / `Node.ELEMENT_NODE`.
+ * @param html - HTML to parse.
+ * @returns The JSDOM window.
+ */
+function createWindow(html: string) {
+ const dom = new JSDOM(html, { url: 'https://example.com' });
+ // recursiveSearch references the global `Node` constant
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (globalThis as any).Node = dom.window.Node;
+ return dom.window;
+}
+
+afterEach(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ delete (globalThis as any).Node;
+});
+
+describe('analyze-search plugin', () => {
+ it('returns label', () => {
+ const plugin = pluginFactory({}, '');
+
+ expect(plugin.label).toBe('キーワード検索');
+ });
+
+ // TODO: toHeader uses Content.search as the key (e.g. "keyword:bar") but
+ // toArray uses Content.title (e.g. "Bar Label"), so eachPage produces
+ // "keyword:Bar Label" as the result key. This mismatch means Content-object
+ // headers never align with their result values in the report output.
+ // Fix: change toArray to use item.search instead of item.title.
+ it('builds headers from keywords and selectors', () => {
+ const plugin = pluginFactory(
+ {
+ keywords: ['foo', { search: 'bar', title: 'Bar Label' }],
+ selectors: ['.nav', { search: '#main', title: 'Main Area' }],
+ },
+ '',
+ );
+
+ expect(plugin.headers).toEqual({
+ 'keyword:foo': 'Search keyword: foo',
+ 'keyword:bar': 'Bar Label',
+ 'selector:.nav': 'Search selector: .nav',
+ 'selector:#main': 'Main Area',
+ });
+ });
+
+ it('returns empty headers when no keywords or selectors are provided', () => {
+ const plugin = pluginFactory({}, '');
+
+ expect(plugin.headers).toEqual({});
+ });
+
+ it('finds keywords in text content', () => {
+ const window = createWindow('Hello World
');
+
+ const plugin = pluginFactory({ keywords: ['Hello'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ 'keyword:Hello': { value: 1 },
+ },
+ });
+ });
+
+ it('returns zero matches for absent keywords', () => {
+ const window = createWindow('Hello World
');
+
+ const plugin = pluginFactory({ keywords: ['NonExistent'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ 'keyword:NonExistent': { value: 0 },
+ },
+ });
+ });
+
+ it('finds selectors that exist on the page', () => {
+ const window = createWindow(
+ '',
+ );
+
+ const plugin = pluginFactory({ selectors: ['.breadcrumb'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ 'selector:.breadcrumb': { value: true },
+ },
+ });
+ });
+
+ it('does not include absent selectors in result', () => {
+ const window = createWindow('No nav
');
+
+ const plugin = pluginFactory({ selectors: ['.breadcrumb'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toEqual({ page: {} });
+ });
+
+ it('returns null when scope element is not found', () => {
+ const window = createWindow('Content
');
+
+ const plugin = pluginFactory({ scope: '#nonexistent', keywords: ['Content'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('scopes keyword search to the specified element', () => {
+ const window = createWindow(`
+
+
+ Main Hello
+
+ `);
+
+ const plugin = pluginFactory({ scope: 'main', keywords: ['Hello'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ // Should find only the single match within , not the one in
+ expect(result).toMatchObject({
+ page: {
+ 'keyword:Hello': { value: 1 },
+ },
+ });
+ });
+
+ it('deduplicates keyword items', () => {
+ const plugin = pluginFactory(
+ {
+ keywords: ['dup', 'dup', 'unique'],
+ },
+ '',
+ );
+
+ // Headers should have deduplicated keys
+ expect(Object.keys(plugin.headers)).toHaveLength(2);
+ });
+
+ it('ignores text inside script and style elements', () => {
+ const window = createWindow(`
+
+
+
+ Visible
+
+ `);
+
+ const plugin = pluginFactory({ keywords: ['Hello'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toMatchObject({
+ page: {
+ 'keyword:Hello': { value: 0 },
+ },
+ });
+ });
+
+ it('handles invalid selectors gracefully', () => {
+ const window = createWindow('Content
');
+
+ const plugin = pluginFactory({ selectors: ['[[[invalid'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ // Should not throw, invalid selectors are caught
+ expect(result).toEqual({ page: {} });
+ });
+
+ it('finds keywords in element attributes (alt, title)', () => {
+ const window = createWindow(
+ '
',
+ );
+
+ const plugin = pluginFactory({ keywords: ['Hello'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ // Should match both alt and title attributes
+ expect(result).toMatchObject({
+ page: {
+ 'keyword:Hello': { value: 2 },
+ },
+ });
+ });
+
+ it('skips excluded attributes (href, src, id, class, style, data-*)', () => {
+ const window = createWindow(
+ 'text',
+ );
+
+ const plugin = pluginFactory({ keywords: ['Hello'] }, '');
+ const result = plugin.eachPage!({
+ url: new URL('https://example.com'),
+ html: '',
+ window: window as never,
+ num: 0,
+ total: 1,
+ });
+
+ // None of the excluded attributes should be matched
+ expect(result).toMatchObject({
+ page: {
+ 'keyword:Hello': { value: 0 },
+ },
+ });
+ });
+});
diff --git a/packages/@nitpicker/analyze-textlint/src/index.spec.ts b/packages/@nitpicker/analyze-textlint/src/index.spec.ts
new file mode 100644
index 0000000..5a07247
--- /dev/null
+++ b/packages/@nitpicker/analyze-textlint/src/index.spec.ts
@@ -0,0 +1,313 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const lintTextMock = vi.fn();
+
+vi.mock('textlint', () => ({
+ createLinter: vi.fn(() => ({
+ lintText: lintTextMock,
+ })),
+}));
+
+vi.mock('@textlint/kernel', () => ({
+ TextlintKernelDescriptor: vi.fn(),
+}));
+
+// Mock all dynamic rule imports
+vi.mock('textlint-rule-no-nfd', () => ({ default: {} }));
+vi.mock('textlint-rule-max-ten', () => ({ default: {} }));
+vi.mock('textlint-rule-spellcheck-tech-word', () => ({ default: {} }));
+vi.mock('textlint-rule-web-plus-db', () => ({ default: {} }));
+vi.mock('textlint-rule-no-mix-dearu-desumasu', () => ({ default: {} }));
+vi.mock('textlint-rule-no-doubled-joshi', () => ({ default: {} }));
+vi.mock('textlint-rule-no-double-negative-ja', () => ({ default: {} }));
+vi.mock('textlint-rule-no-hankaku-kana', () => ({ default: {} }));
+vi.mock('textlint-rule-ja-no-abusage', () => ({ default: {} }));
+vi.mock('textlint-rule-no-mixed-zenkaku-and-hankaku-alphabet', () => ({
+ default: {},
+}));
+vi.mock('textlint-rule-no-dropping-the-ra', () => ({ default: {} }));
+vi.mock('textlint-rule-no-doubled-conjunctive-particle-ga', () => ({
+ default: {},
+}));
+vi.mock('textlint-rule-no-doubled-conjunction', () => ({ default: {} }));
+vi.mock('textlint-rule-ja-no-mixed-period', () => ({ default: {} }));
+vi.mock('textlint-rule-max-appearence-count-of-words', () => ({
+ default: {},
+}));
+vi.mock('textlint-rule-ja-hiragana-keishikimeishi', () => ({
+ default: {},
+}));
+vi.mock('textlint-rule-ja-hiragana-fukushi', () => ({ default: {} }));
+vi.mock('textlint-rule-ja-hiragana-hojodoushi', () => ({ default: {} }));
+vi.mock('textlint-rule-ja-unnatural-alphabet', () => ({ default: {} }));
+vi.mock('@textlint-ja/textlint-rule-no-insert-dropping-sa', () => ({
+ default: {},
+}));
+vi.mock('textlint-rule-prefer-tari-tari', () => ({ default: {} }));
+vi.mock('@textlint-ja/textlint-rule-no-synonyms', () => ({ default: {} }));
+vi.mock('textlint-plugin-html', () => ({ default: {} }));
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let pluginFactory: typeof import('./index.js').default;
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ const mod = await import('./index.js');
+ pluginFactory = mod.default;
+});
+
+describe('analyze-textlint plugin', () => {
+ it('returns label', () => {
+ lintTextMock.mockResolvedValue({ messages: [] });
+
+ const plugin = pluginFactory({}, '');
+
+ expect(plugin.label).toBe('textlint: テキスト校正');
+ });
+
+ it('returns empty violations when no issues found', async () => {
+ lintTextMock.mockResolvedValue({ messages: [] });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: 'Good text',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result).toEqual({ violations: [] });
+ });
+
+ it('maps textlint messages to violations', async () => {
+ lintTextMock.mockResolvedValue({
+ messages: [
+ {
+ severity: 2,
+ ruleId: 'no-nfd',
+ message: 'Found NFD character',
+ line: 3,
+ column: 10,
+ },
+ ],
+ });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations).toEqual([
+ {
+ validator: 'textlint',
+ severity: 'error',
+ rule: 'no-nfd',
+ code: '-',
+ message: 'Found NFD character',
+ url: 'https://example.com/page (3:10)',
+ },
+ ]);
+ });
+
+ it('converts severity 1 to warning', async () => {
+ lintTextMock.mockResolvedValue({
+ messages: [
+ {
+ severity: 1,
+ ruleId: 'max-ten',
+ message: 'Too many commas',
+ line: 1,
+ column: 1,
+ },
+ ],
+ });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations![0]!.severity).toBe('warning');
+ });
+
+ it('converts severity 2 to error', async () => {
+ lintTextMock.mockResolvedValue({
+ messages: [
+ {
+ severity: 2,
+ ruleId: 'test-rule',
+ message: 'Error',
+ line: 1,
+ column: 1,
+ },
+ ],
+ });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations![0]!.severity).toBe('error');
+ });
+
+ it('defaults unknown severity to error', async () => {
+ lintTextMock.mockResolvedValue({
+ messages: [
+ {
+ severity: 99,
+ ruleId: 'test-rule',
+ message: 'Unknown severity',
+ line: 1,
+ column: 1,
+ },
+ ],
+ });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations![0]!.severity).toBe('error');
+ });
+
+ it('passes html and url.pathname + .html to lintText', async () => {
+ lintTextMock.mockResolvedValue({ messages: [] });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/about');
+ await plugin.eachPage!({
+ url,
+ html: 'Test
',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(lintTextMock).toHaveBeenCalledWith('Test
', '/about.html');
+ });
+
+ it('merges user rules over default rules', async () => {
+ const { TextlintKernelDescriptor } = await import('@textlint/kernel');
+ lintTextMock.mockResolvedValue({ messages: [] });
+
+ // Disable a default rule and add a custom override
+ const plugin = pluginFactory(
+ {
+ rules: {
+ 'max-ten': { max: 5 },
+ 'spellcheck-tech-word': false,
+ },
+ },
+ '',
+ );
+
+ await plugin.eachPage!({
+ url: new URL('https://example.com/page'),
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ // TextlintKernelDescriptor receives the resolved rule descriptors
+ const descriptorCall = vi.mocked(TextlintKernelDescriptor).mock.calls[0]![0];
+ // @ts-expect-error -- mock type mismatch
+ const ruleIds = descriptorCall.rules.map((r: { ruleId: string }) => r.ruleId);
+
+ // 'spellcheck-tech-word' should be excluded (set to false)
+ expect(ruleIds).not.toContain('spellcheck-tech-word');
+
+ // 'max-ten' should be present with overridden options
+ // @ts-expect-error -- mock type mismatch
+ const maxTenRule = descriptorCall.rules.find(
+ (r: { ruleId: string }) => r.ruleId === 'max-ten',
+ );
+ expect(maxTenRule.options).toEqual({ max: 5 });
+ });
+
+ it('reuses linter across multiple eachPage calls (lazy singleton)', async () => {
+ const { createLinter } = await import('textlint');
+ lintTextMock.mockResolvedValue({ messages: [] });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/page1');
+
+ await plugin.eachPage!({
+ url,
+ html: 'Page 1
',
+ window: {} as never,
+ num: 0,
+ total: 2,
+ });
+
+ await plugin.eachPage!({
+ url: new URL('https://example.com/page2'),
+ html: 'Page 2
',
+ window: {} as never,
+ num: 1,
+ total: 2,
+ });
+
+ // createLinter should be called only once due to lazy singleton
+ expect(createLinter).toHaveBeenCalledTimes(1);
+ });
+
+ it('collects multiple messages from a single page', async () => {
+ lintTextMock.mockResolvedValue({
+ messages: [
+ {
+ severity: 1,
+ ruleId: 'rule-a',
+ message: 'Warning A',
+ line: 1,
+ column: 1,
+ },
+ {
+ severity: 2,
+ ruleId: 'rule-b',
+ message: 'Error B',
+ line: 5,
+ column: 3,
+ },
+ ],
+ });
+
+ const plugin = pluginFactory({}, '');
+ const url = new URL('https://example.com/page');
+ const result = await plugin.eachPage!({
+ url,
+ html: '',
+ window: {} as never,
+ num: 0,
+ total: 1,
+ });
+
+ expect(result!.violations).toHaveLength(2);
+ expect(result!.violations![0]!.severity).toBe('warning');
+ expect(result!.violations![1]!.severity).toBe('error');
+ });
+});