はじめに
AIコーディングアシスタントがMarkdownを生成・編集する場面が増えています。
人が書いた文章をAIがコンテキストとして読み、AIが生成した文章を人がレビューするという双方向のワークフローも一般的になりつつあります。
こうした環境では、textlint1やremark2といったルールベースのlintを仕組みとして整備しておけば、書き手が人かAIかを問わず同じ基準で品質を担保できます。
たとえば、ですます調の技術ドキュメントに対してAIアシスタントが以下のような文を生成することがあります。
この機能は設定ファイルで有効にできます。
- 内部的にはイベントキューを使用する。
+ 内部的にはイベントキューを使用します。
AIが大量に編集する環境では目視レビューだけでは追いきれません。
textlintのルールで文体の逸脱を自動検出すれば、レビューの負担を軽減できます。
しかし、使い込んでいくと「ここだけルールを変えたい」「この文字をエスケープしないでほしい」といった、既存プラグインでは解決できない問題に出会うことがあります。
本記事では、そんな「かゆいところ」を解消するために筆者が開発・公開した5つのnpmパッケージを紹介します。
| パッケージ | 種別 | 解決する課題 |
|---|---|---|
| textlint-filter-rule-footnote | textlintフィルタールール | 脚注内の特定ルール違反を無視する |
| textlint-rule-footnote-dearu-desumasu | textlintルール | 脚注内の「である/ですます」文体を個別にチェックする |
| textlint-rule-no-plain-form-sentence-ending | textlintルール | ですます調の文書で常体の文末表現を検出する |
| textlint-rule-no-desumasu-in-list-item | textlintルール | 箇条書きの文末にですます調が使われていたら検出する |
| remark-disable-text-escape | remarkプラグイン | remark-stringifyによる特殊文字の不要なエスケープを防ぐ |
5つとも、筆者が個人のドキュメント管理プロジェクトを構築する中で実際に遭遇した問題を解決するために作りました。
背景: 本文は「である調」、脚注は「ですます調」で書きたい
筆者のプロジェクトでは、技術ドキュメントの本文を「である調」で、脚注を「ですます調」で統一しています。
これはMarkdownの仕様である[^1]。
[^1]: 詳細はCommonMark仕様書を参照してください。
しかし、textlintの no-mix-dearu-desumasu ルールはファイル全体を対象にチェックします。
本文が「である調」なのに脚注で「ですます調」を使うと、文体混在として警告が出てしまいます。
この問題を解決するのが、最初の2つのパッケージです。
1. textlint-filter-rule-footnote3
何をするプラグインか
脚注([^id]: ...)の中で発生した特定のルール違反を無視するフィルタールールです。
インストール
npm install textlint-filter-rule-footnote
設定例
.textlintrc.js で、フィルター対象のルールIDを指定します。
{
"filters": {
"footnote": {
"ruleId": [
"ja-technical-writing/no-mix-dearu-desumasu",
"ja-technical-writing/max-comma"
]
}
}
}
ruleId を省略した場合、デフォルトで ja-technical-writing/no-mix-dearu-desumasu が対象になります。
仕組み
実装はシンプルで、textlintの shouldIgnore APIを使っています。
module.exports = (context, options) => {
const { shouldIgnore, Syntax } = context;
const targetRuleIds = Array.isArray(options.ruleId)
? options.ruleId
: [options.ruleId || "ja-technical-writing/no-mix-dearu-desumasu"];
return {
[Syntax.FootnoteDefinition || "footnoteDefinition"](node) {
const range = [
Math.max(0, node.range[0] - 10),
node.range[1] + 10,
];
for (const ruleId of targetRuleIds) {
shouldIgnore(range, { ruleId });
}
},
};
};
FootnoteDefinition ノードを検出したら、そのノード範囲内で指定ルールの報告を抑制します。
rangeに ±10 のパディングを加えているのは、パーサーによってはノード範囲と実際のエラー報告位置にずれが生じるためです。
2. textlint-rule-footnote-dearu-desumasu4
何をするプラグインか
脚注の中だけを対象に、「である調」と「ですます調」の混在をチェックするルールです。
no-mix-dearu-desumasu の脚注専用バージョンと考えてください。
インストール
npm install textlint-rule-footnote-dearu-desumasu
設定例
{
"rules": {
"footnote-dearu-desumasu": {
"prefer": "ですます"
}
}
}
prefer に "ですます" または "である" を指定します。
デフォルトは "ですます" です。
2つのパッケージの組み合わせ
textlint-filter-rule-footnote と textlint-rule-footnote-dearu-desumasu を組み合わせることで、以下のような構成が実現できます。
{
"filters": {
"footnote": {
"ruleId": "ja-technical-writing/no-mix-dearu-desumasu"
}
},
"rules": {
"preset-ja-technical-writing": {
"no-mix-dearu-desumasu": {
"preferInBody": "である",
"preferInHeader": "である",
"preferInList": "である"
}
},
"footnote-dearu-desumasu": {
"prefer": "ですます"
}
}
}
この設定では以下のように動作します。
-
本文:
no-mix-dearu-desumasuが「である調」を強制する -
脚注: フィルタールールが
no-mix-dearu-desumasuの報告を抑制する -
脚注:
footnote-dearu-desumasuが「ですます調」を強制する
本文と脚注で異なる文体を強制しつつ、それぞれの中では文体の一貫性を保てます。
AIは本文の文体に引きずられて脚注まで同じ調子で生成しがちですが、この仕組みなら書き手に依存せず文体の逸脱を自動検出できます。
3. textlint-rule-no-plain-form-sentence-ending5
何をするプラグインか
ですます調の文書で、文末が常体(plain form)になっている箇所を検出するルールです。
no-mix-dearu-desumasu が内部で使っている analyze-desumasu-dearu ライブラリには、「である」は検出するが「だ」や動詞・形容詞の基本形は検出しないという制限があります6。
このルールは、その検出漏れを補完します。
AIもですます調で書いているつもりで「動作する。」「重要だ。」のような常体を混入させることがあり、自動検出の仕組みが品質維持に役立ちます。
検出対象
これは動作する。 → 動詞の基本形(する)
これは重要だ。 → 助動詞「だ」
数が少ない。 → 形容詞の基本形(少ない)
処理が完了した。 → 助動詞「た」(オプション)
インストール
npm install textlint-rule-no-plain-form-sentence-ending
設定例
{
"rules": {
"no-plain-form-sentence-ending": true
}
}
過去形(「した。」「された。」)も検出したい場合は、detectPastTense オプションを有効にします。
{
"rules": {
"no-plain-form-sentence-ending": {
"detectPastTense": true
}
}
}
仕組み
形態素解析エンジン kuromoji を使い、文末トークン(句点の直前にある形態素、つまり文を構成する最小の言語単位)の品詞・活用形を判定しています。
conjugated_form === "基本形" かつ品詞が動詞・形容詞・助動詞(特殊・ダ / 特殊・タ)であれば、常体の文末と判定します。
4. textlint-rule-no-desumasu-in-list-item7
セクション3は本文中の常体混入を検出するものでしたが、箇条書きにも似た問題があります。
何をするプラグインか
箇条書き(リストアイテム)の文末がですます調になっている箇所を検出するルールです。
no-mix-dearu-desumasu には preferInList オプションがあり、箇条書き内の文体を「である調」または「ですます調」に統一できます。
しかし、日本語の技術文書では箇条書きに体言止めを使うのが一般的です。
preferInList の選択肢は「である」か「ですます」の2択で、体言止めを推奨する設定がありません。
このルールは、箇条書きからですます調の混入だけを検出し、体言止めへの修正を促します。
検出対象
- テストを実行します → NG(「ます」で終わっている)
- 問題が発生しました。 → NG(「ました」で終わっている)
- テストの実行 → OK(体言止め)
- 動作しない → OK(常体)
「です」「ます」「でした」「ました」「ません」「ください」「ましょう」「でしょう」の8パターンを検出します。
インストール
npm install textlint-rule-no-desumasu-in-list-item
設定例
{
"rules": {
"no-desumasu-in-list-item": true
}
}
特定のパターンを許可したい場合は、allowPatterns オプションで正規表現文字列の配列を指定します。
{
"rules": {
"no-desumasu-in-list-item": {
"allowPatterns": ["ください"]
}
}
}
仕組み
形態素解析を使わないシンプルな正規表現ベースの実装です。
ListItem ノード内の最初の Paragraph のテキストを取得し、インラインコードやリンクを除去した上で、文末がですます調のパターンに一致するかを判定します。
リンクのみで構成されるリストアイテムはスキップします。
AIは箇条書きを「〜します」「〜です」で生成しがちですが、体言止めに統一すれば人・AI双方にとって読みやすいドキュメントになります。
5. remark-disable-text-escape8
何をするプラグインか
remark-stringify が特殊文字を自動エスケープする動作を無効化するremarkプラグインです。
問題の具体例
remarkでMarkdownを処理(パース→変換→出力)すると、remark-stringify が特殊文字をエスケープします。
入力: foo_bar_baz
出力: foo\_bar\_baz
入力: <http://a_b.com>
出力: [http://a_b.com](http://a_b.com)
入力: [[page_name]]
出力: [[page\_name]]
これはMarkdownの仕様上は正しい動作ですが、以下のようなケースでは不都合が生じます。
- 日本語テキスト中の
_や[が意図せずエスケープされる - Obsidianの
[[WikiLink]]記法が壊れる - URLの括弧やアンパサンドがエスケープされる
@ 記号のエスケープも問題の1つでした。
remarkの新しいバージョンでは @ が \@ へエスケープされるようになり、メールアドレスやメンション表記が壊れるケースがありました。
インストール
npm install remark-disable-text-escape
設定例
// .remarkrc.mjs
import remarkDisableTextEscape from "remark-disable-text-escape";
import remarkWikiLink from "remark-wiki-link";
export default {
plugins: [
[remarkWikiLink, { aliasDivider: "|" }],
[remarkDisableTextEscape, { aliasDivider: "|" }],
],
};
remark-wiki-link と併用する場合は、aliasDivider オプションを揃えてください。
仕組み
3つの戦略でエスケープを防いでいます。
remarkはMarkdownをAST(Abstract Syntax Tree、構文木)に変換してから処理します。
出力時には mdast-util-to-markdown というライブラリがASTをMarkdown文字列に戻しますが、このとき _ や [ などの特殊文字を自動的にエスケープします。
-
カスタムASTノード: テキストノード内の特殊文字を
literalCharという独自定義のノード型に変換します。mdast-util-to-markdownは標準のテキストノードのみをエスケープ対象とするため、独自ノード型に変換された文字はエスケープ処理をスキップします -
カスタムlink/imageハンドラ: autolink(
<URL>形式の自動リンク)の検出と、括弧を含むURLへのangle bracket記法(<>で囲む書き方)適用を担当します -
カスタムwikiLinkハンドラ:
remark-wiki-linkの出力をエスケープなしで直接書き出します
注意点
このプラグインはラウンドトリップ忠実性(round-trip fidelity)を犠牲にしています。
ラウンドトリップ忠実性とは、「Markdown → AST → Markdown」と変換したときに元のMarkdownと同じ結果が得られる性質のことです。
エスケープを無効化しているため、出力されたMarkdownを再度パースすると意味が変わる可能性があります。
出力を最終成果物として使う場合や、自分でコントロールできる環境でのみ使用してください。
エスケープが残ったMarkdownをAIのコンテキストとして与えると、AIがエスケープを模倣して出力に含めることがあるため、クリーンなMarkdownを維持することが重要です。
実際の運用構成
筆者のプロジェクトでは、これら5つのパッケージを以下のように組み合わせています。
textlint設定(.textlintrc.js):
module.exports = {
filters: {
"node-types": { nodeTypes: ["BlockQuote"] },
footnote: {
ruleId: [
"ja-technical-writing/no-mix-dearu-desumasu",
"ja-technical-writing/max-comma",
],
},
},
rules: {
"footnote-dearu-desumasu": true,
"no-plain-form-sentence-ending": { severity: "warning" },
"no-desumasu-in-list-item": { severity: "warning" },
"preset-ja-technical-writing": {
"no-mix-dearu-desumasu": {
preferInBody: "である",
preferInHeader: "である",
preferInList: "である",
},
},
},
};
remark設定(.remarkrc.mjs):
import remarkDisableTextEscape from "remark-disable-text-escape";
import remarkWikiLink from "remark-wiki-link";
export default {
plugins: [
[remarkWikiLink, { aliasDivider: "|" }],
[remarkDisableTextEscape],
],
};
この構成により、以下が実現できています。
- 本文は「である調」、脚注は「ですます調」で統一し、それぞれ自動チェックされる
- ですます調の文書で「する。」「だ。」等の常体混入が警告される
- 箇条書きに「〜します」「〜です」等のですます調が混入したら警告される
- remarkのフォーマット時に
_や[や@がエスケープされない - ObsidianのWikiLink記法が保持される
まとめ
| パッケージ | 解決する課題 |
|---|---|
| textlint-filter-rule-footnote | 脚注内で本文のルールが誤検知する |
| textlint-rule-footnote-dearu-desumasu | 脚注内の文体を個別にチェックしたい |
| textlint-rule-no-plain-form-sentence-ending | ですます調の文書に常体が混入しても検出されない |
| textlint-rule-no-desumasu-in-list-item | 箇条書きの文末にですます調が使われている |
| remark-disable-text-escape | remark-stringifyが特殊文字を不要にエスケープする |
いずれも、日本語ドキュメントをMarkdownで管理するときに遭遇する「ちょっと困る」問題を解決するパッケージです。
しかし、AIと人がMarkdownを共同で読み書きする場面が増えた今、こうしたニッチな問題の解消が書き手に依存しない品質基盤として機能します。
同じ課題に遭遇した方の参考になれば幸いです。