import type { Validator, LintResult, ValidationContext } from '../../../core/types.js'; import { visit } from 'unist-util-visit'; import type { Heading, Text } from 'mdast'; interface HeadingInfo { depth: number; text: string; line: number; } /** * テキスト教材の文書構造検証バリデーター */ export class StructureValidator implements Validator { readonly id = 'text/structure'; readonly name = '文書構造検証'; validate(context: ValidationContext): LintResult[] { const results: LintResult[] = []; const headings = this.collectHeadings(context); // H1見出しの禁止チェック const h1Errors = this.checkH1Headings(headings); results.push(...h1Errors); // {{ toc }}の後に「## はじめに」があるかチェック const introductionError = this.checkIntroductionSection(context, headings); if (introductionError) { results.push(introductionError); } // 学習目標チェック const learningGoalsIndex = headings.findIndex( h => h.depth === 3 && h.text === '学習目標' ); if (learningGoalsIndex === -1) { results.push({ ruleId: `${this.id}/missing-learning-goals`, ruleName: '学習目標セクション必須', severity: 'error', message: '「### 学習目標」セクションがありません', detail: 'テキスト教材には「### 学習目標」セクションが必須です。このセクションでは、この章で学ぶ内容を受講者に明示します。', suggestion: `{{ toc }} の後、概要文章の次に以下のセクションを追加してください: ### 学習目標 [この章の学習目標を説明する段落文章] :::note この章で学ぶこと - [学習目標1] - [学習目標2] - [学習目標3] :::`, }); } // まとめチェック const summaryIndex = headings.findIndex( h => h.depth === 2 && h.text === 'まとめ' ); if (summaryIndex === -1) { results.push({ ruleId: `${this.id}/missing-summary`, ruleName: 'まとめセクション必須', severity: 'error', message: '「## まとめ」セクションがありません', detail: 'テキスト教材の最後には「## まとめ」セクションが必須です。このセクションでは、学習した内容の要点をまとめ、次の章への展開を行います。', suggestion: `ファイルの最後に以下のセクションを追加してください: ## まとめ [このページで学習した内容を要約して読者の定着を図る] :::note 要点のまとめ - [要点1] - [要点2] - [要点3] ::: [次のページの内容を簡潔に紹介する] [次のページへのリンク](./next-page)`, }); } // まとめが最後のH2かチェック if (summaryIndex !== -1) { const h2sAfterSummary = headings .slice(summaryIndex + 1) .filter(h => h.depth === 2); if (h2sAfterSummary.length > 0) { results.push({ ruleId: `${this.id}/summary-not-last`, ruleName: 'まとめ位置', severity: 'warning', message: '「## まとめ」が最後のH2セクションではありません', detail: '「## まとめ」セクションはテキスト教材の最後のH2セクションとして配置する必要があります。', suggestion: '「## まとめ」セクションを最後に移動してください。', location: { line: headings[summaryIndex].line, column: 1, }, }); } } // セクション順序チェック(学習目標がまとめより後にある場合) if (learningGoalsIndex !== -1 && summaryIndex !== -1) { if (learningGoalsIndex > summaryIndex) { results.push({ ruleId: `${this.id}/invalid-order`, ruleName: 'セクション順序', severity: 'error', message: 'セクションの順序が不正です(学習目標がまとめより後にあります)', detail: '「### 学習目標」セクションは「## まとめ」セクションより前に配置する必要があります。正しい順序: 概要 → 学習目標 → 学習項目 → まとめ', suggestion: 'セクションの順序を以下のように修正してください:\n\n1. 概要\n2. ### 学習目標\n3. 各学習項目(## 見出し)\n4. ## まとめ', location: { line: headings[learningGoalsIndex].line, column: 1, }, }); } } return results; } /** * H1見出しがないことをチェック * テキスト教材ではH1見出しは使用禁止(タイトルはフロントマターで指定) */ private checkH1Headings(headings: HeadingInfo[]): LintResult[] { const results: LintResult[] = []; const h1Headings = headings.filter(h => h.depth === 1); for (const h1 of h1Headings) { results.push({ ruleId: `${this.id}/h1-not-allowed`, ruleName: 'H1見出し禁止', severity: 'error', message: `H1見出し「# ${h1.text}」は使用できません`, detail: 'テキスト教材ではH1見出し(# )は使用できません。タイトルはフロントマターの title で指定されるため、本文中にH1見出しを記述する必要はありません。', suggestion: `以下のH1見出しを削除してください:\n\n# ${h1.text}\n\n※ タイトルはフロントマターの title フィールドで自動的に表示されます。`, location: { line: h1.line, column: 1, }, }); } return results; } /** * {{ toc }}の直後に「## はじめに」があるかチェック */ private checkIntroductionSection(context: ValidationContext, headings: HeadingInfo[]): LintResult | null { const { content } = context; const lines = content.split('\n'); // {{ toc }}の行を探す const tocLineIndex = lines.findIndex(line => line.trim() === '{{ toc }}'); // {{ toc }}がない場合はチェックしない if (tocLineIndex === -1) { return null; } const tocLine = tocLineIndex + 1; // 1-indexed // {{ toc }}の後の最初のH2を探す const h2Headings = headings.filter(h => h.depth === 2); const firstH2AfterToc = h2Headings.find(h => h.line > tocLine); // {{ toc }}の後にH2がない、または最初のH2が「はじめに」でない場合はエラー if (!firstH2AfterToc || firstH2AfterToc.text !== 'はじめに') { return { ruleId: `${this.id}/missing-introduction`, ruleName: 'はじめにセクション必須', severity: 'error', message: '{{ toc }}の直後に「## はじめに」セクションがありません', detail: 'テキスト教材では、{{ toc }}の直後に「## はじめに」セクションを配置し、このテキストの概要を説明する必要があります。', suggestion: `{{ toc }} の直後に以下のセクションを追加してください: ## はじめに [このテキストの概要を説明する文章] ### 学習目標 [学習目標の説明]`, location: { line: tocLine, column: 1, }, }; } return null; } /** * ASTから見出し情報を収集する */ private collectHeadings(context: ValidationContext): HeadingInfo[] { const headings: HeadingInfo[] = []; visit(context.tree, 'heading', (node: Heading) => { // 見出しのテキストを抽出 let text = ''; for (const child of node.children) { if (child.type === 'text') { text += (child as Text).value; } } headings.push({ depth: node.depth, text: text.trim(), line: node.position?.start.line ?? 0, }); }); return headings; } }