diff --git a/README.md b/README.md index 33bb09e..ff85ef9 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,34 @@ command - `disableQuote`: `true`にするとコロン後の引用検出を無効にする - `disableTable`: `true`にするとコロン後のテーブル検出を無効にする +### no-ai-heading-numbers + +AIが機械的に生成しがちな、見出しに連番を含めるパターンを検出します。 + +#### 検出される例 + +```markdown +# 1. タイトル +## 2. セクション +### 3.1. サブセクション +#### 4) 項目 +``` + +#### より自然な表現 + +```markdown +# タイトル +## セクション +### サブセクション +#### 項目 +``` + +#### オプション + +- `allows`: 指定したパターンにマッチする場合、エラーを報告しません + - 文字列: `"許可したいテキスト"` + - 正規表現: `"/パターン/フラグ"` (例: `"/\\d+\\. .*/i"`) + ### ai-tech-writing-guideline テクニカルライティングのベストプラクティスに基づいて、文書品質の改善提案を行います。 @@ -335,6 +363,9 @@ command "disableQuote": false, "disableTable": false }, + "no-ai-heading-numbers": { + "allows": ["許可したいテキスト", "/正規表現パターン/"] + }, "ai-tech-writing-guideline": { "severity": "info", // サジェストとして扱う "allows": ["許可したいテキスト", "/正規表現パターン/"], diff --git a/src/index.ts b/src/index.ts index 9fb85ed..bbe7f56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import aiTechWritingGuideline from "./rules/ai-tech-writing-guideline"; import noAiColonContinuation from "./rules/no-ai-colon-continuation"; import noAiEmphasisPatterns from "./rules/no-ai-emphasis-patterns"; +import noAiHeadingNumbers from "./rules/no-ai-heading-numbers"; import noAiHypeExpressions from "./rules/no-ai-hype-expressions"; import noAiListFormatting from "./rules/no-ai-list-formatting"; @@ -10,7 +11,8 @@ const preset = { "no-ai-hype-expressions": noAiHypeExpressions, "no-ai-emphasis-patterns": noAiEmphasisPatterns, "ai-tech-writing-guideline": aiTechWritingGuideline, - "no-ai-colon-continuation": noAiColonContinuation + "no-ai-colon-continuation": noAiColonContinuation, + "no-ai-heading-numbers": noAiHeadingNumbers }, rulesConfig: { "no-ai-list-formatting": true, @@ -19,7 +21,8 @@ const preset = { "ai-tech-writing-guideline": { severity: "info" }, - "no-ai-colon-continuation": true + "no-ai-colon-continuation": true, + "no-ai-heading-numbers": true } }; diff --git a/src/rules/no-ai-heading-numbers.ts b/src/rules/no-ai-heading-numbers.ts new file mode 100644 index 0000000..c71e873 --- /dev/null +++ b/src/rules/no-ai-heading-numbers.ts @@ -0,0 +1,56 @@ +import { matchPatterns } from "@textlint/regexp-string-matcher"; +import type { TextlintRuleContext, TextlintRuleModule } from "@textlint/types"; + +type Options = { + // If node's text includes allowed patterns, does not report. + // Can be string or RegExp-like string ("/pattern/flags") + allows?: string[]; +}; + +const rule: TextlintRuleModule = (context: TextlintRuleContext, options = {}) => { + const { Syntax, RuleError, report, getSource, locator } = context; + const allows = options.allows ?? []; + + // Pattern to detect numbering at the beginning of headings + // Matches patterns like: "1. ", "2. ", "3.1. ", "1) ", etc. + const numberingPattern = /^\s*(\d+(?:\.\d+)*[.)]\s+)/; + + return { + [Syntax.Header](node) { + // Use raw text which includes the heading marker (#) + const text = node.raw || getSource(node); + + // Check if text matches any allowed patterns + if (allows.length > 0) { + const matches = matchPatterns(text, allows); + if (matches.length > 0) { + return; + } + } + + // For raw text, we need to skip the heading marker first + // Extract just the heading content (after # and space) + const headingContentMatch = text.match(/^#{1,6}\s+(.+)$/); + const headingContent = headingContentMatch ? headingContentMatch[1] : text; + + const match = headingContent.match(numberingPattern); + if (match) { + const numberPart = match[1]; + const matchStart = match.index ?? 0; + const matchEnd = matchStart + numberPart.length; + + report( + node, + new RuleError( + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + { + padding: locator.range([matchStart, matchEnd]) + } + ) + ); + } + } + }; +}; + +export default rule; diff --git a/test/rules/no-ai-heading-numbers.test.ts b/test/rules/no-ai-heading-numbers.test.ts new file mode 100644 index 0000000..14922fa --- /dev/null +++ b/test/rules/no-ai-heading-numbers.test.ts @@ -0,0 +1,115 @@ +import TextLintTester from "textlint-tester"; +import noAiHeadingNumbers from "../../src/rules/no-ai-heading-numbers"; + +const tester = new TextLintTester(); + +tester.run("no-ai-heading-numbers", noAiHeadingNumbers, { + valid: [ + // Normal headings without numbering + "# タイトル", + "## セクション", + "### サブセクション", + "#### 詳細項目", + "##### 更に詳細な項目", + "###### 最小レベルの見出し", + // Headings with text that happens to start with numbers (but no dot/paren) + "# 2025年の振り返り", + "## 3つのポイント", + // Allowed patterns (string) + { + text: "# 1. イントロダクション", + options: { + allows: ["1. イントロダクション"] + } + }, + // Allowed patterns (RegExp-like string) + { + text: "## 2. 背景", + options: { + allows: ["/\\d+\\. .*/"] + } + }, + // Allowed patterns (case insensitive) + { + text: "### 3.1. サブセクション", + options: { + allows: ["/\\d+\\.\\d+\\. .*/i"] + } + } + ], + invalid: [ + // Simple numbering patterns + { + text: "# 1. タイトル", + errors: [ + { + message: + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + range: [0, 3] + } + ] + }, + { + text: "## 2. セクション", + errors: [ + { + message: + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + range: [0, 3] + } + ] + }, + { + text: "### 10. 10番目のアイテム", + errors: [ + { + message: + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + range: [0, 4] + } + ] + }, + // Hierarchical numbering (1.1, 1.2, etc.) + { + text: "### 3.1. サブセクション", + errors: [ + { + message: + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + range: [0, 5] + } + ] + }, + { + text: "#### 1.2.3. 深い階層", + errors: [ + { + message: + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + range: [0, 7] + } + ] + }, + // Parenthesis style numbering + { + text: "# 1) はじめに", + errors: [ + { + message: + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + range: [0, 3] + } + ] + }, + { + text: "## 2) 背景", + errors: [ + { + message: + "見出しに連番を含めるパターンは機械的な印象を与える可能性があります。連番の必要性を検討してみてください。", + range: [0, 3] + } + ] + } + ] +});