import type { Validator, ValidationContext, LintResult } from '../../../core/types.js'; import { visit } from 'unist-util-visit'; import type { Code } from 'mdast'; /** * コードブロック検証バリデーター * - 言語指定の有無をチェック * - step内: プログラムコードの前にファイルパス(_path/to/file_)があるかチェック * - step内: bashコードの前に「_コマンド実行_」があるかチェック * - step外: コードブロックの前に何らかのタイトルがあるかチェック * - コードハイライトマーカーの有無をチェック(プログラムコードのみ、step内のみ) */ export class CodeBlockValidator implements Validator { readonly id = 'text/code-block'; readonly name = 'コードブロック検証'; // 言語指定チェックをスキップする言語はない(全てチェック) // ファイルパス/コマンドタイトルチェックをスキップする言語 private readonly skipTitleCheckLanguages = ['text', 'plaintext', 'diff', 'output', 'console']; // bashとして扱う言語(コマンドブロック) private readonly bashLanguages = ['bash', 'sh', 'shell', 'zsh', 'cmd', 'powershell', 'ps1']; // ハイライトマーカーの種類 private readonly highlightMarkers = [ { start: '//addstart', end: '//addend', name: 'add' }, { start: '//delstart', end: '//delend', name: 'del' }, { start: '//highlightstart', end: '//highlightend', name: 'highlight' }, { start: '//errorstart', end: '//errorend', name: 'error' }, { start: '//warningstart', end: '//warningend', name: 'warning' }, ]; validate(context: ValidationContext): LintResult[] { const results: LintResult[] = []; const { tree, content } = context; const lines = content.split('\n'); // stepブロックの範囲を事前に計算 const stepRanges = this.findStepRanges(lines); visit(tree, 'code', (node: Code) => { const line = node.position?.start.line; const column = node.position?.start.column ?? 1; // 言語指定チェック if (!node.lang) { results.push({ ruleId: `${this.id}/missing-language`, ruleName: 'コードブロック言語指定', severity: 'warning', message: 'コードブロックに言語指定がありません', detail: 'コードブロックには言語を指定してシンタックスハイライトを有効にしてください。', suggestion: '```の後に言語名を追加してください(例: ```typescript, ```bash)', location: line ? { line, column } : undefined, }); return; } const lang = node.lang.toLowerCase(); // スキップする言語はチェックしない if (this.skipTitleCheckLanguages.includes(lang)) { return; } // コードブロックの直前の行をチェック const prevContent = this.getPreviousParagraphContent(lines, line); // step内かどうかを判定 const isInsideStep = this.isInsideStepBlock(line, stepRanges); if (isInsideStep) { // step内: 厳密なルールを適用 // bash系の言語 if (this.bashLanguages.includes(lang)) { if (!this.hasCommandTitle(prevContent)) { results.push({ ruleId: `${this.id}/missing-command-title`, ruleName: 'コマンドブロックタイトル', severity: 'warning', message: 'コマンドブロックの前に「_コマンド実行_」がありません', detail: 'ターミナルで実行するコマンドブロックの前には「_コマンド実行_」を記述し、読者がコマンドを実行すべき箇所であることを明示してください。', suggestion: 'コードブロックの直前に以下を追加してください:\n\n_コマンド実行_', location: line ? { line, column } : undefined, }); } // コマンドブロックはハイライトチェックをスキップ return; } // プログラミング言語 if (!this.hasFilePath(prevContent)) { results.push({ ruleId: `${this.id}/missing-filepath`, ruleName: 'コードブロックファイルパス', severity: 'warning', message: 'プログラムコードブロックの前にファイルパスがありません', detail: 'プログラムファイルを追加・更新するコードブロックの前には、ファイルパスを `_` で囲って記述する必要があります。', suggestion: 'コードブロックの直前に以下の形式でファイルパスを追加してください:\n\n_src/path/to/file.ts_\n\n```' + lang, location: line ? { line, column } : undefined, }); } // コードハイライトチェック(プログラムコードのみ、step内のみ) const codeContent = node.value || ''; const highlightErrors = this.checkHighlightMarkers(codeContent, line, column); results.push(...highlightErrors); } else { // step外: 何らかのタイトルがあればOK if (!this.hasAnyTitle(prevContent)) { results.push({ ruleId: `${this.id}/missing-title`, ruleName: 'コードブロックタイトル', severity: 'warning', message: 'コードブロックの前にタイトルがありません', detail: 'コードブロックの前には、そのコードの概要がわかるタイトルを記述してください。', suggestion: 'コードブロックの直前に、コードの内容を説明するタイトルを追加してください。\n\n例:\n\n_設定ファイルの例_\n\n```' + lang, location: line ? { line, column } : undefined, }); } } }); return results; } /** * コードハイライトマーカーをチェック */ private checkHighlightMarkers(codeContent: string, line: number | undefined, column: number): LintResult[] { const results: LintResult[] = []; // 閉じられていないマーカーをチェック for (const marker of this.highlightMarkers) { const hasStart = codeContent.includes(marker.start); const hasEnd = codeContent.includes(marker.end); if (hasStart && !hasEnd) { results.push({ ruleId: `${this.id}/unclosed-highlight`, ruleName: 'コードハイライト閉じタグ', severity: 'error', message: `コードハイライトマーカー「${marker.start}」に対応する「${marker.end}」がありません`, detail: `コードハイライトマーカーは必ず開始タグと終了タグのペアで使用してください。「${marker.start}」で開始した場合は「${marker.end}」で閉じる必要があります。`, suggestion: `「${marker.end}」を追加してハイライト範囲を閉じてください。\n\n${marker.start}\nコード\n${marker.end}`, location: line ? { line, column } : undefined, }); } } // ハイライトマーカーがあるかチェック const hasAnyHighlight = this.highlightMarkers.some(marker => codeContent.includes(marker.start) && codeContent.includes(marker.end) ); if (!hasAnyHighlight) { results.push({ ruleId: `${this.id}/missing-highlight`, ruleName: 'コードハイライト', severity: 'warning', message: 'コードブロックにハイライトマーカーがありません', detail: 'コードブロックには、読者の注目すべき箇所を示すハイライトマーカーを追加してください。追加部分は //addstart〜//addend、削除部分は //delstart〜//delend、強調部分は //highlightstart〜//highlightend、エラー部分は //errorstart〜//errorend、警告部分は //warningstart〜//warningend を使用します。', suggestion: '適切なハイライトマーカーを追加してください:\n\n```\n//addstart\n追加するコード\n//addend\n```\n\nまたは\n\n```\n//highlightstart\n強調するコード\n//highlightend\n```', location: line ? { line, column } : undefined, }); } return results; } /** * コードブロックの直前の段落内容を取得 */ private getPreviousParagraphContent(lines: string[], codeLine: number | undefined): string { if (!codeLine || codeLine <= 1) { return ''; } // コードブロックの直前の行から上に向かって探索 // 空行をスキップして最初の非空行を見つける for (let i = codeLine - 2; i >= 0; i--) { const line = lines[i].trim(); if (line === '') { continue; } // コードブロック開始行自体をスキップ if (line.startsWith('```')) { continue; } return line; } return ''; } /** * 「コマンド実行」を含むかチェック(部分一致) */ private hasCommandTitle(content: string): boolean { // 「コマンド実行」「コマンドを実行」などの部分一致を許容 return /コマンド.{0,2}実行/.test(content); } /** * _filepath_ 形式のファイルパスがあるかチェック(部分一致) * タイトル(_で囲まれた部分)の中にファイルパス(拡張子付き)が含まれていればOK */ private hasFilePath(content: string): boolean { // _で囲まれた部分を抽出 const italicMatch = content.match(/_([^_]+)_/); if (!italicMatch) { return false; } const italicContent = italicMatch[1]; // イタリック内にファイルパスパターン(拡張子を含むパス)が含まれているかチェック // 例: src/index.ts, package.json, ./config/settings.yaml // パス + 拡張子のパターン(相対パス、絶対パス、ファイル名のみ全て対応) const filePathPattern = /(?:\.?\.?\/)?[a-zA-Z0-9_\-./]*[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+/; return filePathPattern.test(italicContent); } /** * 何らかのタイトルがあるかチェック(step外用) * 空でない行があればタイトルとみなす */ private hasAnyTitle(content: string): boolean { return content.trim().length > 0; } /** * :::step ブロックの範囲を取得 */ private findStepRanges(lines: string[]): Array<{ start: number; end: number }> { const ranges: Array<{ start: number; end: number }> = []; let inStepBlock = false; let startLine = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line === ':::step') { inStepBlock = true; startLine = i + 1; // 1-indexed } else if (inStepBlock && line === ':::') { ranges.push({ start: startLine, end: i + 1, // 1-indexed }); inStepBlock = false; } } return ranges; } /** * 指定行がstepブロック内かどうかを判定 */ private isInsideStepBlock(line: number | undefined, stepRanges: Array<{ start: number; end: number }>): boolean { if (!line) { return false; } return stepRanges.some(range => line > range.start && line < range.end); } }