diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 065fb3a..f82fd3a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -196,12 +196,13 @@ crawler/src/ ### @nitpicker/cli -`@d-zero/roar` ベースの統合 CLI。4つのサブコマンドを提供。全 analyze プラグインを `dependencies` に含んでおり、`npx` 実行時に `@nitpicker/core` の動的 `import()` がプラグインモジュールを解決できるようにしている。 +`@d-zero/roar` ベースの統合 CLI。5つのサブコマンドを提供。全 analyze プラグインを `dependencies` に含んでおり、`npx` 実行時に `@nitpicker/core` の動的 `import()` がプラグインモジュールを解決できるようにしている。 - **`npx @nitpicker/cli crawl `**: Webサイトをクロールして `.nitpicker` ファイルを生成 - **`npx @nitpicker/cli analyze `**: `.nitpicker` ファイルに対して analyze プラグインを実行。`--search-keywords`, `--axe-lang` 等のフラグで設定ファイルのプラグイン設定を上書き可能(`buildPluginOverrides()` → `Nitpicker.setPluginOverrides()` 経由) - **`npx @nitpicker/cli report `**: `.nitpicker` ファイルから Google Sheets レポートを生成 - **`npx @nitpicker/cli pipeline `**: crawl → analyze → report を直列実行。`startCrawl()` でアーカイブパスを取得し、そのパスを `analyze()` と `report()` に引き渡す。`--sheet` 指定時のみ report ステップを実行 +- **`npx @nitpicker/cli query `**: `.nitpicker` ファイルに対してクエリを実行し、結果を JSON で出力。`@nitpicker/query` の全関数を CLI から利用可能。12 のサブコマンド(`summary`, `pages`, `page-detail`, `html`, `links`, `resources`, `images`, `violations`, `duplicates`, `mismatches`, `headers`, `resource-referrers`)を提供 --- diff --git a/CLAUDE.md b/CLAUDE.md index 792c1f8..e57428d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,10 +48,11 @@ packages/ ## CLI コマンド ```sh -npx @nitpicker/cli crawl [options] # Web サイトをクロールして .nitpicker ファイルを生成 -npx @nitpicker/cli analyze [options] # .nitpicker ファイルに対して analyze プラグインを実行 -npx @nitpicker/cli report [options] # .nitpicker ファイルから Google Sheets レポートを生成 -npx @nitpicker/cli pipeline [options] # crawl → analyze → report を直列実行 +npx @nitpicker/cli crawl [options] # Web サイトをクロールして .nitpicker ファイルを生成 +npx @nitpicker/cli analyze [options] # .nitpicker ファイルに対して analyze プラグインを実行 +npx @nitpicker/cli report [options] # .nitpicker ファイルから Google Sheets レポートを生成 +npx @nitpicker/cli pipeline [options] # crawl → analyze → report を直列実行 +npx @nitpicker/cli query [options] # .nitpicker ファイルに対してクエリを実行し JSON 出力 ``` ## 主要アーキテクチャ diff --git a/README.md b/README.md index a2f6588..8cd198d 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,101 @@ $ npx @nitpicker/cli pipeline https://example.com --all --silent --strict crawl コマンドと同じ終了コード体系に従う。詳細は [crawl の終了コード](#終了コード) を参照。 +### Query + +`.nitpicker` アーカイブファイルに対してクエリを実行し、結果を JSON で出力する。MCP サーバーと同等のクエリ機能を CLI から利用できる。 + +```sh +$ npx @nitpicker/cli query [options] +``` + +#### サブコマンド + +| サブコマンド | 説明 | 必須オプション | +| -------------------- | -------------------------------------------------------------- | -------------- | +| `summary` | サイト全体の概要(ページ数、ステータス分布、メタデータ充足率) | なし | +| `pages` | ページ一覧(ステータス・メタデータ欠損・noindex 等で絞り込み) | なし | +| `page-detail` | 特定ページの全詳細(メタデータ、リンク、リダイレクト) | `--url` | +| `html` | ページの HTML スナップショットを取得 | `--url` | +| `links` | リンク分析(broken / external / orphaned) | `--type` | +| `resources` | サブリソース一覧(CSS, JS, 画像、フォント) | なし | +| `images` | 画像一覧(alt 欠損、寸法欠損、オーバーサイズ検出) | なし | +| `violations` | 分析プラグインの違反データ | なし | +| `duplicates` | 重複タイトル・説明の検出 | なし | +| `mismatches` | メタデータ不一致の検出 | `--type` | +| `headers` | セキュリティヘッダーチェック | なし | +| `resource-referrers` | 特定リソースを参照しているページの特定 | `--url` | + +#### オプション + +| オプション | 値 | デフォルト | 説明 | +| ---------------------- | ------ | ---------- | --------------------------------------------------------------------------------------------------- | +| `--limit` `-l` | 数値 | なし | 最大結果数 | +| `--offset` `-o` | 数値 | なし | スキップする結果数 | +| `--url` | URL | なし | 対象 URL(`page-detail`, `html`, `resource-referrers` で必須) | +| `--status` | 数値 | なし | HTTP ステータスコードで絞り込み | +| `--statusMin` | 数値 | なし | 最小 HTTP ステータスコード(以上) | +| `--statusMax` | 数値 | なし | 最大 HTTP ステータスコード(以下) | +| `--isExternal` | なし | なし | 外部ページのみ表示 | +| `--missingTitle` | なし | なし | title 欠損ページのみ表示 | +| `--missingDescription` | なし | なし | description 欠損ページのみ表示 | +| `--noindex` | なし | なし | noindex ページのみ表示 | +| `--urlPattern` | 文字列 | なし | URL パターンで絞り込み(SQL LIKE パターン) | +| `--directory` | 文字列 | なし | ディレクトリパスプレフィックスで絞り込み | +| `--sortBy` | 文字列 | なし | ソートフィールド(`url`, `status`, `title`) | +| `--sortOrder` | 文字列 | なし | ソート方向(`asc`, `desc`) | +| `--type` | 文字列 | なし | `links`: `broken`, `external`, `orphaned` / `mismatches`: `canonical`, `og:title`, `og:description` | +| `--contentType` | 文字列 | なし | Content-Type プレフィックスで絞り込み(例: `text/css`) | +| `--missingAlt` | なし | なし | alt 属性欠損の画像のみ表示 | +| `--missingDimensions` | なし | なし | 寸法欠損の画像のみ表示 | +| `--oversizedThreshold` | 数値 | なし | 指定寸法を超える画像のみ表示 | +| `--validator` | 文字列 | なし | バリデータ名で絞り込み(例: `axe`, `markuplint`) | +| `--severity` | 文字列 | なし | 重要度で絞り込み | +| `--rule` | 文字列 | なし | ルール ID で絞り込み | +| `--field` | 文字列 | `title` | 重複チェック対象フィールド(`title`, `description`) | +| `--missingOnly` | なし | なし | セキュリティヘッダー欠損ページのみ表示 | +| `--maxLength` | 数値 | なし | 返却する HTML の最大長 | +| `--pretty` | なし | なし | JSON 出力を整形表示 | + +> **`--type` フラグの使い分け**: `links` サブコマンドでは `broken`, `external`, `orphaned` のいずれか、`mismatches` サブコマンドでは `canonical`, `og:title`, `og:description` のいずれかを指定する。 + +#### 例 + +```sh +# サイト概要を取得 +$ npx @nitpicker/cli query site.nitpicker summary + +# ページ一覧(ステータスコード 404 で絞り込み) +$ npx @nitpicker/cli query site.nitpicker pages --status 404 + +# 特定ページの詳細 +$ npx @nitpicker/cli query site.nitpicker page-detail --url "https://example.com/about" + +# HTML スナップショット取得(最大 10000 文字) +$ npx @nitpicker/cli query site.nitpicker html --url "https://example.com" --maxLength 10000 + +# リンク切れ一覧 +$ npx @nitpicker/cli query site.nitpicker links --type broken + +# alt 欠損画像の一覧 +$ npx @nitpicker/cli query site.nitpicker images --missingAlt + +# アクセシビリティ違反の一覧 +$ npx @nitpicker/cli query site.nitpicker violations --validator axe + +# 重複タイトルの検出 +$ npx @nitpicker/cli query site.nitpicker duplicates --field title + +# canonical 不一致の検出 +$ npx @nitpicker/cli query site.nitpicker mismatches --type canonical + +# セキュリティヘッダー欠損ページ +$ npx @nitpicker/cli query site.nitpicker headers --missingOnly + +# 整形出力 +$ npx @nitpicker/cli query site.nitpicker summary --pretty +``` + ### MCP Server `.nitpicker` アーカイブファイルを AI アシスタント(Claude 等)から直接クエリするための [Model Context Protocol](https://modelcontextprotocol.io/) サーバー。14 のツールを提供し、サイト構造・メタデータ・リンク・リソース・画像・セキュリティヘッダーなどを対話的に分析できる。 diff --git a/packages/@nitpicker/cli/package.json b/packages/@nitpicker/cli/package.json index a89b1a6..46ebf2d 100644 --- a/packages/@nitpicker/cli/package.json +++ b/packages/@nitpicker/cli/package.json @@ -43,6 +43,7 @@ "@nitpicker/analyze-textlint": "0.5.0", "@nitpicker/core": "0.5.0", "@nitpicker/crawler": "0.5.0", + "@nitpicker/query": "0.5.0", "@nitpicker/report-google-sheets": "0.5.1", "ansi-colors": "4.1.3", "debug": "4.4.3", diff --git a/packages/@nitpicker/cli/src/cli.ts b/packages/@nitpicker/cli/src/cli.ts index 9e7585c..cd47b36 100644 --- a/packages/@nitpicker/cli/src/cli.ts +++ b/packages/@nitpicker/cli/src/cli.ts @@ -3,6 +3,7 @@ import { parseCli } from '@d-zero/roar'; import { analyze, commandDef as analyzeDef } from './commands/analyze.js'; import { crawl, commandDef as crawlDef } from './commands/crawl.js'; import { pipeline, commandDef as pipelineDef } from './commands/pipeline.js'; +import { query, commandDef as queryDef } from './commands/query.js'; import { report, commandDef as reportDef } from './commands/report.js'; import { ExitCode } from './exit-code.js'; import { formatCliError } from './format-cli-error.js'; @@ -16,6 +17,7 @@ const cli = parseCli({ analyze: analyzeDef, report: reportDef, pipeline: pipelineDef, + query: queryDef, }, onError: () => true, }); @@ -38,6 +40,10 @@ try { await pipeline(cli.args, cli.flags); break; } + case 'query': { + await query(cli.args, cli.flags); + break; + } } } catch (error) { formatCliError(error, true); diff --git a/packages/@nitpicker/cli/src/commands/query.spec.ts b/packages/@nitpicker/cli/src/commands/query.spec.ts new file mode 100644 index 0000000..58393cd --- /dev/null +++ b/packages/@nitpicker/cli/src/commands/query.spec.ts @@ -0,0 +1,168 @@ +import path from 'node:path'; + +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; + +import { formatCliError as formatCliErrorFn } from '../format-cli-error.js'; +import { dispatchQuery as dispatchQueryFn } from '../query/dispatch-query.js'; + +import { query } from './query.js'; + +vi.mock('@nitpicker/query', () => ({ + ArchiveManager: vi.fn().mockImplementation(function (this: { + open: ReturnType; + close: ReturnType; + }) { + this.open = vi.fn().mockResolvedValue({ + archiveId: 'archive_1', + accessor: {}, + }); + this.close = vi.fn().mockResolvedValue(); + }), +})); + +vi.mock('../query/dispatch-query.js', () => ({ + dispatchQuery: vi + .fn() + .mockResolvedValue({ baseUrl: 'https://example.com', totalPages: 5 }), +})); + +vi.mock('../format-cli-error.js', () => ({ + formatCliError: vi.fn(), +})); + +/** Sentinel error thrown by the process.exit mock to halt execution. */ +class ExitError extends Error { + /** The exit code passed to process.exit(). */ + readonly code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +describe('query command', () => { + let exitSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ExitError(code as number); + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exits with error when no file path is provided', async () => { + await expect(query([], {} as never)).rejects.toThrow(ExitError); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: No .nitpicker file specified.'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Usage: nitpicker query [options]', + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with error when no sub-command is provided', async () => { + await expect(query(['test.nitpicker'], {} as never)).rejects.toThrow(ExitError); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: No sub-command specified.'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Valid sub-commands:'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits with error for unknown sub-command', async () => { + await expect(query(['test.nitpicker', 'unknown'], {} as never)).rejects.toThrow( + ExitError, + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: Unknown sub-command: unknown'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('outputs JSON result to stdout on success', async () => { + await query(['test.nitpicker', 'summary'], { pretty: undefined } as never); + + expect(dispatchQueryFn).toHaveBeenCalledWith( + expect.anything(), + 'summary', + expect.objectContaining({ pretty: undefined }), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify({ baseUrl: 'https://example.com', totalPages: 5 }), + ); + }); + + it('exits with error when ArchiveManager.open fails', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + vi.mocked(ArchiveManager).mockImplementationOnce(function (this: { + open: ReturnType; + close: ReturnType; + }) { + this.open = vi.fn().mockRejectedValue(new Error('Failed to open archive')); + this.close = vi.fn().mockResolvedValue(); + } as never); + + await expect( + query(['test.nitpicker', 'summary'], { pretty: undefined } as never), + ).rejects.toThrow(ExitError); + + expect(formatCliErrorFn).toHaveBeenCalledWith(expect.any(Error), false); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('resolves relative file path via process.cwd()', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + await query(['relative/test.nitpicker', 'summary'], { pretty: undefined } as never); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.open).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'relative/test.nitpicker'), + ); + }); + + it('uses absolute file path as-is', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + await query(['/absolute/test.nitpicker', 'summary'], { pretty: undefined } as never); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.open).toHaveBeenCalledWith('/absolute/test.nitpicker'); + }); + + it('pretty-prints when --pretty is set', async () => { + await query(['test.nitpicker', 'summary'], { pretty: true } as never); + + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify({ baseUrl: 'https://example.com', totalPages: 5 }, null, 2), + ); + }); + + it('closes archive after successful query', async () => { + const { ArchiveManager } = await import('@nitpicker/query'); + await query(['test.nitpicker', 'summary'], { pretty: undefined } as never); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.close).toHaveBeenCalledWith('archive_1'); + }); + + it('closes archive even when dispatch throws', async () => { + vi.mocked(dispatchQueryFn).mockRejectedValueOnce(new Error('Query failed')); + const { ArchiveManager } = await import('@nitpicker/query'); + + await expect( + query(['test.nitpicker', 'summary'], { pretty: undefined } as never), + ).rejects.toThrow(ExitError); + + const managerInstance = vi.mocked(ArchiveManager).mock.results[0]?.value; + expect(managerInstance.close).toHaveBeenCalledWith('archive_1'); + expect(formatCliErrorFn).toHaveBeenCalledWith(expect.any(Error), false); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/@nitpicker/cli/src/commands/query.ts b/packages/@nitpicker/cli/src/commands/query.ts new file mode 100644 index 0000000..f15615f --- /dev/null +++ b/packages/@nitpicker/cli/src/commands/query.ts @@ -0,0 +1,189 @@ +import type { QuerySubCommand } from '../query/types.js'; +import type { CommandDef, InferFlags } from '@d-zero/roar'; + +import path from 'node:path'; + +import { ArchiveManager } from '@nitpicker/query'; + +import { formatCliError } from '../format-cli-error.js'; +import { dispatchQuery } from '../query/dispatch-query.js'; +import { VALID_SUB_COMMANDS } from '../query/types.js'; + +/** + * Command definition for the `query` sub-command. + * @see {@link query} for the main entry point + */ +export const commandDef = { + desc: 'Query a .nitpicker archive', + flags: { + limit: { + type: 'number', + shortFlag: 'l', + desc: 'Maximum number of results to return', + }, + offset: { + type: 'number', + shortFlag: 'o', + desc: 'Number of results to skip', + }, + url: { + type: 'string', + desc: 'Target URL for page-detail, html, or resource-referrers queries', + }, + status: { + type: 'number', + desc: 'Filter by exact HTTP status code', + }, + statusMin: { + type: 'number', + desc: 'Filter by minimum HTTP status code (inclusive)', + }, + statusMax: { + type: 'number', + desc: 'Filter by maximum HTTP status code (inclusive)', + }, + isExternal: { + type: 'boolean', + desc: 'Filter by external (true) or internal (false)', + }, + missingTitle: { + type: 'boolean', + desc: 'Filter to pages missing title', + }, + missingDescription: { + type: 'boolean', + desc: 'Filter to pages missing description', + }, + noindex: { + type: 'boolean', + desc: 'Filter to pages with noindex', + }, + urlPattern: { + type: 'string', + desc: 'URL pattern to filter (SQL LIKE pattern)', + }, + directory: { + type: 'string', + desc: 'Directory path prefix to filter by', + }, + sortBy: { + type: 'string', + desc: 'Field to sort by (url, status, title)', + }, + sortOrder: { + type: 'string', + desc: 'Sort direction (asc, desc)', + }, + type: { + type: 'string', + desc: 'Filter type: broken, external, orphaned (links) or canonical, og:title, og:description (mismatches)', + }, + contentType: { + type: 'string', + desc: 'Filter by content type prefix (e.g. text/css)', + }, + missingAlt: { + type: 'boolean', + desc: 'Filter to images missing alt attribute', + }, + missingDimensions: { + type: 'boolean', + desc: 'Filter to images missing width/height', + }, + oversizedThreshold: { + type: 'number', + desc: 'Filter to images exceeding this dimension threshold', + }, + validator: { + type: 'string', + desc: 'Filter by validator name (e.g. axe, markuplint)', + }, + severity: { + type: 'string', + desc: 'Filter by severity level', + }, + rule: { + type: 'string', + desc: 'Filter by rule ID', + }, + field: { + type: 'string', + desc: 'Field to check for duplicates (title, description)', + }, + missingOnly: { + type: 'boolean', + desc: 'Only show pages missing security headers', + }, + maxLength: { + type: 'number', + desc: 'Maximum HTML length to return', + }, + pretty: { + type: 'boolean', + desc: 'Pretty-print JSON output', + }, + }, +} as const satisfies CommandDef; + +/** Parsed flag values for the `query` CLI command. */ +type QueryFlags = InferFlags; + +/** + * Main entry point for the `query` CLI command. + * + * Opens a `.nitpicker` archive, dispatches the specified sub-command + * to the appropriate `@nitpicker/query` function, and prints the result + * as JSON to stdout. + * @param args - Positional arguments; first is the `.nitpicker` file path, second is the sub-command. + * @param flags - Parsed CLI flags from the `query` command. + * @returns Resolves when the query is complete. + * Exits with code 1 if arguments are missing/invalid or an error occurs. + */ +export async function query(args: string[], flags: QueryFlags) { + const filePath = args[0]; + if (!filePath) { + // eslint-disable-next-line no-console + console.error('Error: No .nitpicker file specified.'); + // eslint-disable-next-line no-console + console.error('Usage: nitpicker query [options]'); + process.exit(1); + } + + const subCommandArg = args[1]; + if (!subCommandArg || !VALID_SUB_COMMANDS.includes(subCommandArg as QuerySubCommand)) { + // eslint-disable-next-line no-console + console.error( + subCommandArg + ? `Error: Unknown sub-command: ${subCommandArg}` + : 'Error: No sub-command specified.', + ); + // eslint-disable-next-line no-console + console.error(`Valid sub-commands: ${VALID_SUB_COMMANDS.join(', ')}`); + process.exit(1); + } + + const subCommand = subCommandArg as QuerySubCommand; + const absFilePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(process.cwd(), filePath); + + const manager = new ArchiveManager(); + try { + const { archiveId, accessor } = await manager.open(absFilePath); + try { + const result = await dispatchQuery(accessor, subCommand, flags); + const output = JSON.stringify(result, null, flags.pretty ? 2 : undefined); + // eslint-disable-next-line no-console + console.log(output); + } finally { + try { + await manager.close(archiveId); + } catch { + // close failure should not mask the original error + } + } + } catch (error) { + formatCliError(error, false); + process.exit(1); + } +} diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts new file mode 100644 index 0000000..cced4eb --- /dev/null +++ b/packages/@nitpicker/cli/src/query/dispatch-query.spec.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; + +import { dispatchQuery } from './dispatch-query.js'; + +vi.mock('@nitpicker/query', () => ({ + getSummary: vi + .fn() + .mockResolvedValue({ baseUrl: 'https://example.com', totalPages: 10 }), + listPages: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + getPageDetail: vi.fn().mockResolvedValue({ url: 'https://example.com', status: 200 }), + getPageHtml: vi.fn().mockResolvedValue({ html: '', truncated: false }), + listLinks: vi.fn().mockResolvedValue({ items: [], total: 0 }), + listResources: vi + .fn() + .mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + listImages: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + getViolations: vi.fn().mockResolvedValue({ items: [], total: 0 }), + findDuplicates: vi.fn().mockResolvedValue([]), + findMismatches: vi.fn().mockResolvedValue([]), + checkHeaders: vi.fn().mockResolvedValue({ items: [], total: 0, offset: 0, limit: 100 }), + getResourceReferrers: vi.fn().mockResolvedValue({ + resourceUrl: 'https://example.com/style.css', + pageUrls: [], + total: 0, + }), + ArchiveManager: vi.fn(), +})); + +/** Mock accessor for testing dispatch calls. */ +const mockAccessor = {} as never; + +/** Default empty flags for testing. */ +const emptyFlags = {} as never; + +describe('dispatchQuery', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('dispatches summary sub-command', async () => { + const { getSummary } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'summary', emptyFlags); + expect(result).toEqual({ baseUrl: 'https://example.com', totalPages: 10 }); + expect(getSummary).toHaveBeenCalledWith(mockAccessor); + }); + + it('dispatches pages sub-command', async () => { + const { listPages } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'pages', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(listPages).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); + }); + + it('dispatches page-detail sub-command', async () => { + const { getPageDetail } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'page-detail', { + url: 'https://example.com', + } as never); + expect(result).toEqual({ url: 'https://example.com', status: 200 }); + expect(getPageDetail).toHaveBeenCalledWith(mockAccessor, 'https://example.com'); + }); + + it('throws when page-detail returns null', async () => { + const { getPageDetail } = await import('@nitpicker/query'); + vi.mocked(getPageDetail).mockResolvedValueOnce(null); + + await expect( + dispatchQuery(mockAccessor, 'page-detail', { url: 'https://missing.com' } as never), + ).rejects.toThrow('Page not found: https://missing.com'); + }); + + it('dispatches html sub-command', async () => { + const { getPageHtml } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'html', { + url: 'https://example.com', + } as never); + expect(result).toEqual({ html: '', truncated: false }); + expect(getPageHtml).toHaveBeenCalledWith( + mockAccessor, + 'https://example.com', + undefined, + ); + }); + + it('dispatches html sub-command with maxLength', async () => { + const { getPageHtml } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'html', { + url: 'https://example.com', + maxLength: 5000, + } as never); + expect(result).toEqual({ html: '', truncated: false }); + expect(getPageHtml).toHaveBeenCalledWith(mockAccessor, 'https://example.com', 5000); + }); + + it('throws when html returns null', async () => { + const { getPageHtml } = await import('@nitpicker/query'); + vi.mocked(getPageHtml).mockResolvedValueOnce(null); + + await expect( + dispatchQuery(mockAccessor, 'html', { url: 'https://missing.com' } as never), + ).rejects.toThrow('Page HTML not found: https://missing.com'); + }); + + it('dispatches links sub-command', async () => { + const { listLinks } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'links', { + type: 'broken', + } as never); + expect(result).toEqual({ items: [], total: 0 }); + expect(listLinks).toHaveBeenCalledWith( + mockAccessor, + expect.objectContaining({ type: 'broken' }), + ); + }); + + it('dispatches resources sub-command', async () => { + const { listResources } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'resources', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(listResources).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); + }); + + it('dispatches images sub-command', async () => { + const { listImages } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'images', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(listImages).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); + }); + + it('dispatches violations sub-command', async () => { + const { getViolations } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'violations', emptyFlags); + expect(result).toEqual({ items: [], total: 0 }); + expect(getViolations).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); + }); + + it('dispatches duplicates sub-command with default field', async () => { + const { findDuplicates } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'duplicates', emptyFlags); + expect(result).toEqual([]); + expect(findDuplicates).toHaveBeenCalledWith(mockAccessor, 'title', undefined); + }); + + it('dispatches duplicates sub-command with custom field and limit', async () => { + const { findDuplicates } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'duplicates', { + field: 'description', + limit: 10, + } as never); + expect(result).toEqual([]); + expect(findDuplicates).toHaveBeenCalledWith(mockAccessor, 'description', 10); + }); + + it('dispatches mismatches sub-command', async () => { + const { findMismatches } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'mismatches', { + type: 'canonical', + } as never); + expect(result).toEqual([]); + expect(findMismatches).toHaveBeenCalledWith( + mockAccessor, + 'canonical', + undefined, + undefined, + ); + }); + + it('dispatches mismatches sub-command with limit and offset', async () => { + const { findMismatches } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'mismatches', { + type: 'og:title', + limit: 5, + offset: 10, + } as never); + expect(result).toEqual([]); + expect(findMismatches).toHaveBeenCalledWith(mockAccessor, 'og:title', 5, 10); + }); + + it('dispatches headers sub-command', async () => { + const { checkHeaders } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'headers', emptyFlags); + expect(result).toEqual({ items: [], total: 0, offset: 0, limit: 100 }); + expect(checkHeaders).toHaveBeenCalledWith(mockAccessor, expect.any(Object)); + }); + + it('dispatches headers sub-command with missingOnly', async () => { + const { checkHeaders } = await import('@nitpicker/query'); + await dispatchQuery(mockAccessor, 'headers', { + missingOnly: true, + limit: 25, + } as never); + expect(checkHeaders).toHaveBeenCalledWith( + mockAccessor, + expect.objectContaining({ missingOnly: true, limit: 25 }), + ); + }); + + it('dispatches resource-referrers sub-command', async () => { + const { getResourceReferrers } = await import('@nitpicker/query'); + const result = await dispatchQuery(mockAccessor, 'resource-referrers', { + url: 'https://example.com/style.css', + } as never); + expect(result).toEqual({ + resourceUrl: 'https://example.com/style.css', + pageUrls: [], + total: 0, + }); + expect(getResourceReferrers).toHaveBeenCalledWith( + mockAccessor, + 'https://example.com/style.css', + ); + }); + + it('throws when resource-referrers returns null', async () => { + const { getResourceReferrers } = await import('@nitpicker/query'); + vi.mocked(getResourceReferrers).mockResolvedValueOnce(null); + + await expect( + dispatchQuery(mockAccessor, 'resource-referrers', { + url: 'https://missing.com/style.css', + } as never), + ).rejects.toThrow('Resource not found: https://missing.com/style.css'); + }); +}); diff --git a/packages/@nitpicker/cli/src/query/dispatch-query.ts b/packages/@nitpicker/cli/src/query/dispatch-query.ts new file mode 100644 index 0000000..8bc6bae --- /dev/null +++ b/packages/@nitpicker/cli/src/query/dispatch-query.ts @@ -0,0 +1,121 @@ +import type { QuerySubCommand } from './types.js'; +import type { commandDef } from '../commands/query.js'; +import type { InferFlags } from '@d-zero/roar'; +import type { ArchiveAccessor } from '@nitpicker/crawler'; +import type { + ListPagesOptions, + ListLinksOptions, + ListResourcesOptions, + ListImagesOptions, + GetViolationsOptions, +} from '@nitpicker/query'; + +import { + getSummary, + listPages, + getPageDetail, + getPageHtml, + listLinks, + listResources, + listImages, + getViolations, + findDuplicates, + findMismatches, + checkHeaders, + getResourceReferrers, +} from '@nitpicker/query'; + +import { mapFlagsToQueryOptions } from './map-flags-to-query-options.js'; + +/** Parsed flag values for the query CLI command. */ +type QueryFlags = InferFlags; + +/** + * Dispatches a query sub-command to the appropriate `@nitpicker/query` function. + * + * Maps each sub-command name to its corresponding query function, builds the + * options from CLI flags via {@link mapFlagsToQueryOptions}, and returns the + * JSON-serializable result. + * @param accessor - The opened archive accessor. + * @param subCommand - The sub-command name. + * @param flags - The parsed CLI flags. + * @returns The query result as a JSON-serializable value. + * @throws {Error} If a required resource is not found (page-detail, html, resource-referrers). + */ +export async function dispatchQuery( + accessor: ArchiveAccessor, + subCommand: QuerySubCommand, + flags: QueryFlags, +): Promise { + const options = mapFlagsToQueryOptions(subCommand, flags); + + switch (subCommand) { + case 'summary': { + return getSummary(accessor); + } + case 'pages': { + return listPages(accessor, options as ListPagesOptions); + } + case 'page-detail': { + const { url } = options as { url: string }; + const result = await getPageDetail(accessor, url); + if (!result) { + throw new Error(`Page not found: ${url}`); + } + return result; + } + case 'html': { + const { url, maxLength } = options as { url: string; maxLength?: number }; + const result = await getPageHtml(accessor, url, maxLength); + if (!result) { + throw new Error(`Page HTML not found: ${url}`); + } + return result; + } + case 'links': { + return listLinks(accessor, options as ListLinksOptions); + } + case 'resources': { + return listResources(accessor, options as ListResourcesOptions); + } + case 'images': { + return listImages(accessor, options as ListImagesOptions); + } + case 'violations': { + return getViolations(accessor, options as GetViolationsOptions); + } + case 'duplicates': { + const { field, limit } = options as { + field: 'title' | 'description'; + limit?: number; + }; + return findDuplicates(accessor, field, limit); + } + case 'mismatches': { + const { type, limit, offset } = options as { + type: 'canonical' | 'og:title' | 'og:description'; + limit?: number; + offset?: number; + }; + return findMismatches(accessor, type, limit, offset); + } + case 'headers': { + return checkHeaders( + accessor, + options as { limit?: number; offset?: number; missingOnly?: boolean }, + ); + } + case 'resource-referrers': { + const { url } = options as { url: string }; + const result = await getResourceReferrers(accessor, url); + if (!result) { + throw new Error(`Resource not found: ${url}`); + } + return result; + } + default: { + const _exhaustive: never = subCommand; + throw new Error(`Unknown sub-command: ${String(_exhaustive)}`); + } + } +} diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts new file mode 100644 index 0000000..5692311 --- /dev/null +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.spec.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; + +import { mapFlagsToQueryOptions } from './map-flags-to-query-options.js'; + +describe('mapFlagsToQueryOptions', () => { + it('returns empty object for summary', () => { + expect(mapFlagsToQueryOptions('summary', {})).toEqual({}); + }); + + it('maps pages flags correctly', () => { + const result = mapFlagsToQueryOptions('pages', { + status: 404, + statusMin: 400, + statusMax: 499, + isExternal: false, + missingTitle: true, + sortBy: 'status', + sortOrder: 'desc', + limit: 50, + offset: 10, + }); + expect(result).toEqual({ + status: 404, + statusMin: 400, + statusMax: 499, + isExternal: false, + missingTitle: true, + missingDescription: undefined, + noindex: undefined, + urlPattern: undefined, + directory: undefined, + sortBy: 'status', + sortOrder: 'desc', + limit: 50, + offset: 10, + }); + }); + + it('throws for invalid sortBy value', () => { + expect(() => mapFlagsToQueryOptions('pages', { sortBy: 'invalid' })).toThrow( + 'Invalid --sortBy value', + ); + }); + + it('throws for invalid sortOrder value', () => { + expect(() => mapFlagsToQueryOptions('pages', { sortOrder: 'invalid' })).toThrow( + 'Invalid --sortOrder value', + ); + }); + + it('requires --url for page-detail', () => { + expect(() => mapFlagsToQueryOptions('page-detail', {})).toThrow( + '--url is required for the page-detail sub-command', + ); + }); + + it('returns url for page-detail', () => { + expect(mapFlagsToQueryOptions('page-detail', { url: 'https://example.com' })).toEqual( + { + url: 'https://example.com', + }, + ); + }); + + it('requires --url for html', () => { + expect(() => mapFlagsToQueryOptions('html', {})).toThrow( + '--url is required for the html sub-command', + ); + }); + + it('returns url and maxLength for html', () => { + expect( + mapFlagsToQueryOptions('html', { url: 'https://example.com', maxLength: 5000 }), + ).toEqual({ + url: 'https://example.com', + maxLength: 5000, + }); + }); + + it('requires --type for links', () => { + expect(() => mapFlagsToQueryOptions('links', {})).toThrow( + '--type is required for the links sub-command', + ); + }); + + it('throws for invalid links type', () => { + expect(() => mapFlagsToQueryOptions('links', { type: 'invalid' })).toThrow( + 'Invalid --type value', + ); + }); + + it('maps links flags correctly', () => { + expect( + mapFlagsToQueryOptions('links', { type: 'broken', limit: 20, offset: 5 }), + ).toEqual({ + type: 'broken', + limit: 20, + offset: 5, + }); + }); + + it('maps resources flags correctly', () => { + expect( + mapFlagsToQueryOptions('resources', { contentType: 'text/css', limit: 10 }), + ).toEqual({ + contentType: 'text/css', + isExternal: undefined, + limit: 10, + offset: undefined, + }); + }); + + it('maps images flags correctly', () => { + expect( + mapFlagsToQueryOptions('images', { missingAlt: true, oversizedThreshold: 1000 }), + ).toEqual({ + missingAlt: true, + missingDimensions: undefined, + oversizedThreshold: 1000, + urlPattern: undefined, + limit: undefined, + offset: undefined, + }); + }); + + it('maps violations flags correctly', () => { + expect( + mapFlagsToQueryOptions('violations', { validator: 'axe', severity: 'critical' }), + ).toEqual({ + validator: 'axe', + severity: 'critical', + rule: undefined, + limit: undefined, + offset: undefined, + }); + }); + + it('defaults duplicates field to title', () => { + expect(mapFlagsToQueryOptions('duplicates', {})).toEqual({ + field: 'title', + limit: undefined, + }); + }); + + it('throws for invalid duplicates field', () => { + expect(() => mapFlagsToQueryOptions('duplicates', { field: 'invalid' })).toThrow( + 'Invalid --field value', + ); + }); + + it('requires --type for mismatches', () => { + expect(() => mapFlagsToQueryOptions('mismatches', {})).toThrow( + '--type is required for the mismatches sub-command', + ); + }); + + it('throws for invalid mismatches type', () => { + expect(() => mapFlagsToQueryOptions('mismatches', { type: 'broken' })).toThrow( + 'Invalid --type value', + ); + }); + + it('maps mismatches flags correctly', () => { + expect( + mapFlagsToQueryOptions('mismatches', { type: 'canonical', limit: 50 }), + ).toEqual({ + type: 'canonical', + limit: 50, + offset: undefined, + }); + }); + + it('maps headers flags correctly', () => { + expect(mapFlagsToQueryOptions('headers', { missingOnly: true, limit: 25 })).toEqual({ + limit: 25, + offset: undefined, + missingOnly: true, + }); + }); + + it('requires --url for resource-referrers', () => { + expect(() => mapFlagsToQueryOptions('resource-referrers', {})).toThrow( + '--url is required for the resource-referrers sub-command', + ); + }); + + it('returns url for resource-referrers', () => { + expect( + mapFlagsToQueryOptions('resource-referrers', { + url: 'https://example.com/style.css', + }), + ).toEqual({ + url: 'https://example.com/style.css', + }); + }); +}); diff --git a/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts new file mode 100644 index 0000000..39a518f --- /dev/null +++ b/packages/@nitpicker/cli/src/query/map-flags-to-query-options.ts @@ -0,0 +1,155 @@ +import type { QuerySubCommand } from './types.js'; +import type { commandDef } from '../commands/query.js'; +import type { InferFlags } from '@d-zero/roar'; + +/** Parsed flag values for the query CLI command. */ +type QueryFlags = InferFlags; + +/** + * Builds the options object for a specific query function from flat CLI flags. + * + * Validates required flags per sub-command and returns the appropriate + * options shape for the corresponding `@nitpicker/query` function. + * @param subCommand - The query sub-command name. + * @param flags - The parsed CLI flags. + * @returns The options object appropriate for the sub-command's query function. + * @throws {Error} If required flags are missing or have invalid values. + */ +export function mapFlagsToQueryOptions( + subCommand: QuerySubCommand, + flags: QueryFlags, +): unknown { + switch (subCommand) { + case 'summary': { + return {}; + } + case 'pages': { + if (flags.sortBy != null && !['url', 'status', 'title'].includes(flags.sortBy)) { + throw new Error( + `Invalid --sortBy value: ${flags.sortBy}. Must be one of: url, status, title`, + ); + } + if (flags.sortOrder != null && !['asc', 'desc'].includes(flags.sortOrder)) { + throw new Error( + `Invalid --sortOrder value: ${flags.sortOrder}. Must be one of: asc, desc`, + ); + } + return { + status: flags.status, + statusMin: flags.statusMin, + statusMax: flags.statusMax, + isExternal: flags.isExternal, + missingTitle: flags.missingTitle, + missingDescription: flags.missingDescription, + noindex: flags.noindex, + urlPattern: flags.urlPattern, + directory: flags.directory, + sortBy: flags.sortBy as 'url' | 'status' | 'title' | undefined, + sortOrder: flags.sortOrder as 'asc' | 'desc' | undefined, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'page-detail': { + if (!flags.url) { + throw new Error('--url is required for the page-detail sub-command.'); + } + return { url: flags.url }; + } + case 'html': { + if (!flags.url) { + throw new Error('--url is required for the html sub-command.'); + } + return { url: flags.url, maxLength: flags.maxLength }; + } + case 'links': { + if (!flags.type) { + throw new Error( + '--type is required for the links sub-command. Must be one of: broken, external, orphaned', + ); + } + if (!['broken', 'external', 'orphaned'].includes(flags.type)) { + throw new Error( + `Invalid --type value: ${flags.type}. Must be one of: broken, external, orphaned`, + ); + } + return { + type: flags.type as 'broken' | 'external' | 'orphaned', + limit: flags.limit, + offset: flags.offset, + }; + } + case 'resources': { + return { + contentType: flags.contentType, + isExternal: flags.isExternal, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'images': { + return { + missingAlt: flags.missingAlt, + missingDimensions: flags.missingDimensions, + oversizedThreshold: flags.oversizedThreshold, + urlPattern: flags.urlPattern, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'violations': { + return { + validator: flags.validator, + severity: flags.severity, + rule: flags.rule, + limit: flags.limit, + offset: flags.offset, + }; + } + case 'duplicates': { + if (flags.field != null && !['title', 'description'].includes(flags.field)) { + throw new Error( + `Invalid --field value: ${flags.field}. Must be one of: title, description`, + ); + } + return { + field: (flags.field as 'title' | 'description' | undefined) ?? 'title', + limit: flags.limit, + }; + } + case 'mismatches': { + if (!flags.type) { + throw new Error( + '--type is required for the mismatches sub-command. Must be one of: canonical, og:title, og:description', + ); + } + if (!['canonical', 'og:title', 'og:description'].includes(flags.type)) { + throw new Error( + `Invalid --type value: ${flags.type}. Must be one of: canonical, og:title, og:description`, + ); + } + return { + type: flags.type as 'canonical' | 'og:title' | 'og:description', + limit: flags.limit, + offset: flags.offset, + }; + } + case 'headers': { + return { + limit: flags.limit, + offset: flags.offset, + missingOnly: flags.missingOnly, + }; + } + case 'resource-referrers': { + if (!flags.url) { + throw new Error('--url is required for the resource-referrers sub-command.'); + } + return { url: flags.url }; + } + default: { + const _exhaustive: never = subCommand; + throw new Error(`Unknown sub-command: ${String(_exhaustive)}`); + } + } +} diff --git a/packages/@nitpicker/cli/src/query/types.ts b/packages/@nitpicker/cli/src/query/types.ts new file mode 100644 index 0000000..378d850 --- /dev/null +++ b/packages/@nitpicker/cli/src/query/types.ts @@ -0,0 +1,34 @@ +/** + * Valid sub-command names for the query command. + */ +export type QuerySubCommand = + | 'summary' + | 'pages' + | 'page-detail' + | 'html' + | 'links' + | 'resources' + | 'images' + | 'violations' + | 'duplicates' + | 'mismatches' + | 'headers' + | 'resource-referrers'; + +/** + * List of all valid query sub-command names. + */ +export const VALID_SUB_COMMANDS = [ + 'summary', + 'pages', + 'page-detail', + 'html', + 'links', + 'resources', + 'images', + 'violations', + 'duplicates', + 'mismatches', + 'headers', + 'resource-referrers', +] as const satisfies readonly QuerySubCommand[]; diff --git a/packages/@nitpicker/cli/tsconfig.json b/packages/@nitpicker/cli/tsconfig.json index 3f2366b..f04cea3 100644 --- a/packages/@nitpicker/cli/tsconfig.json +++ b/packages/@nitpicker/cli/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../core" }, { "path": "../crawler" }, + { "path": "../query" }, { "path": "../report-google-sheets" } ], "include": ["./src/**/*"], diff --git a/yarn.lock b/yarn.lock index a4e0b86..bd2fc14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2385,6 +2385,7 @@ __metadata: "@nitpicker/analyze-textlint": "npm:0.5.0" "@nitpicker/core": "npm:0.5.0" "@nitpicker/crawler": "npm:0.5.0" + "@nitpicker/query": "npm:0.5.0" "@nitpicker/report-google-sheets": "npm:0.5.1" "@types/debug": "npm:4.1.12" ansi-colors: "npm:4.1.3"