TL;DR
追記:修正が入ったPrettier 3.0がリリースされました。
プロジェクト内にそれをインストールした場合、本プラグインは不要となりました。
https://prettier.io/blog/2023/07/05/3.0.0.html#markdown
なお、VS Codeプラグイン内蔵のものは2023年7月現在まだ2系列で問題が残っています。
日本語MarkdownをPrettierでフォーマットするなら拙作のprettier-plugin-md-nocjsp
を入れましょう。
さもないと
このように Markdown 中の英数文字と日本語の間に容赦なく半角スペース U+0020 が挿入されます。
組版の和欧間スペースと英単語間のスペースは幅が違います。似ていますが絶対に許してはなりません。
追記:この問題はバージョン3で修正される予定ですました。
お断り
Prettierやこの問題の発端となった人物には結構恨みを持っているため、以下ところどころそれがにじみ出た文章になっています。ご了承ください。
ことの発端
VSCode の Prettier 拡張プラグインを入れて Markdown を書き、フォーマットしたら英数文字と日本語の間に半角スペースが入っていた。
日本語組版で和欧間にアキを空けなければならないことは知っていたので、最初は気に留めていなかったが、後々調べてみるととんでもないことに・・・
日本語・中国語組版におけるアキの扱い
まず、前提として日本語・中国語はUnicode上区別できません。この問題を混入させたのが中国人で、この修正は中国語にも影響が出るので、根深い問題です。(当初は韓国語にも影響が出ていましたが、幸いハングルは漢字仮名と区別可能なため、現在は修正されています)
ここでは要点だけかいつまんで説明しますが、(詳しい出典はリポジトリREADMEで引用しています)日本語中国語共組版では、漢字仮名と英数文字の間に、全角の1/4幅のスペース(四分アキ)を挿入しなければなりません。
ただし、中国語に限り、半角スペースで代用が可能とのことです。おそらく今回の問題はこれが発端となったのでしょう。
それに対して、英単語間のスペースは、
- 日本語では全角の1/3幅(三分アキ)
- 中国語ではフォント次第
と規定されています。Wordでは半角スペースが全角の1/3や1/4でなかったため、中国語側のルールを採用しているようです。(欧文も書く際にはメリットでしかないので一長一短ですが)
少なくとも日本語では半角スペースは幅広すぎます。
厄介なことに、一度入り込んだスペースを闇雲に消すのは大変危険です。次のようなスペースは正当です。
作る means make in English.
ですので、なるべく早い段階で、スペースが新規に入り込むのを阻止する必要があります。
GitHubにIssueが上がっていたが放置状態
Prettierにバグ報告をしようと思ったら先客がいました。がいました。しかし、2019年から放置状態でした。このままだとスペースを挿入される被害文書は増える一方です。いち早く止めねばならない。
お前どこの馬の骨かわかんねえガイドライン鵜呑みにしてんだよ
犯人がこんな変更を追加した動機を示唆した投稿をしていました。
While not adding option is good but removing a feature is not a good idea IMO. From what I saw on Twitter, some people did enjoy using this feature. I'm not sure how uncommon it is for Japanese but it's the recommended format for Chinese [Chinese docs] at least. If we're going to remove this feature, I'd say we should provide some alternative tools (or plugins) at the same time so that we won't bother people too much.
オプションを追加しないのは良いことですが、個人的に機能を削除するのはいい考えではないと思います。Twitterを見ていると、この機能の恩恵を受けている人もいました。日本語ではどれくらい珍しいのかわかりませんが、中国語では中国語文書の推奨フォーマットになってます。もしこの機能を削除するのなら、同時にいくつかの代替ツール(またはプラグイン)を提供して、あまり人に迷惑をかけないようにした方がいいと思います。
他の方にこんな反論をされていました。
No. Your reference is just a personal guide in chinese world. Check this official text layout guide.
In a word, the space between cjk characters and latin characters is a pollution. This work should be done by the layout engine instead of changing the text directly. So i think at least there should be an option to close this "feature" while removing it is a more wise action.
いいえ、あなたが参考にしているのは、中国語圏内での個人的なルールでしかありません。この公式組版ガイドを見てください。
一言で言えば、日中韓文字とラテン文字の間のスペースは汚染です。この処理は、テキストを直接変更するのではなく、レイアウトエンジンが行うべきです。ですから、少なくともこの「機能」を閉鎖するオプションがあるべきだと思いますが、削除する方がより賢明な行動だと思います。
ごもっとも。よくぞ言ってくれた。その後犯人からは一切の言動なし。逃げてんじゃねえよ。
しかも、オフで中国人数名に聞く機会を持てたので聞いてみましたが、誰一人として、漢字と英数間にスペースを入れていませんでした。そもそも中国政府の公式HPですら入れてないんだからメジャーなわけないだろ。
オプション追加禁止とかふざけんな、ならプラグイン作って解決だ
それでもPrettier側にオプションを追加する意向はなし。このままでは議論は平行線の一途です。
修正版フォーク(しかも作者は犯人と同じく中国人で草)も作られる有様。
この状況は看過できません。環境変数を使う手もないわけではありませんが、提案も面倒でこれ以上厄介者になるわけにもいかなかったので、プラグインを自分で作ることにしました。できたものがこちらです。
どうやって使う?
npm
・yarn
等でdevDependencies
としてインストールしてください。Prettierをリポジトリにインストールしてない場合、予めインストールしておいてください。(npm i -D prettier
/ yarn add -D prettier
など)
# ↓利用しているパッケージマネージャに合わせてどちらかまたは相当するコマンドを実行
npm i -D prettier-plugin-md-nocjsp
yarn add -D prettier-plugin-md-nocjsp
以下の内容の.prettierrc
を作成し、VSCodeの拡張なり、yarn prettier -w .
(npm -c "prettier -w ."
)なりでフォーマットをかけてください。
# *snip*
overrides:
- files:
- "*.md"
- README
options:
parser: markdown-nocjsp
- files:
- "*.mdx"
options:
parser: mdx-nocjsp
やっべ素のPrettierにMarkdownめちゃくちゃにされちゃった、許さーん
バージョン1.4.0からquickFix
オプションを実装しました。漢字仮名と英数字に挟まれたスペースを問答無用で除去します。100%とはいきませんが、いい線は行くと思います。.prettierrc
などに指定すれば使えます。(1回だけ適用後は、当該オプションを除去して構いません。むしろ今後の副作用の懸念から推奨します。)
# *snip*
overrides:
- files:
- "*.md"
- README
options:
parser: markdown-nocjsp
quickFix: true
- files:
- "*.mdx"
options:
parser: mdx-nocjsp
quickFix: true
以下技術的な話となります。
どうやってPrettierの処理を流用する?
ソースを見た結果、Markdownのプリンタが悪さ(更にいうとユーティリティ関数から汚染)をしている模様。パーサなど、他の処理はなるべくPrettier本体から使い回したい。Prettierをインポートしても目当ての処理はできなそう(該当関数がexport
されていないので無理)なので、ソースコードをサブモジュールとして取ってきて、使い回せるところはrequire
先をいじって使い回し、変更すべきところはコピペすることで解決しました。
コピペ対象としたのはsrc/language-markdown
ディレクトリ内の一部のファイルです。そのうち、export
されているため流用可能な処理については、サブモジュールの本家ソースからrequire
するように変更をし、コピペ先からは削除しています。
最大の改変対象は、src/language-markdown/utils.js
内の関数splitText
内部の関数appendNode
です。他の方がオプションを追加しようと頑張ったPR内容をベースとし、オプション用の処理を消しました。
function appendNode(node) {
const lastNode = getLast(nodes);
if (lastNode && lastNode.type === "word") {
if (
(lastNode.kind === KIND_NON_CJK &&
node.kind === KIND_CJ_LETTER &&
!lastNode.hasTrailingPunctuation) ||
(lastNode.kind === KIND_CJ_LETTER &&
node.kind === KIND_NON_CJK &&
!node.hasLeadingPunctuation)
) {
nodes.push({ type: "whitespace", value: " " });
} else if (
!isBetween(KIND_NON_CJK, KIND_CJK_PUNCTUATION) &&
// disallow leading/trailing full-width whitespace
![lastNode.value, node.value].some((value) => /\u3000/.test(value))
) {
nodes.push({ type: "whitespace", value: "" });
}
}
nodes.push(node);
function isBetween(kind1, kind2) {
return (
(lastNode.kind === kind1 && node.kind === kind2) ||
(lastNode.kind === kind2 && node.kind === kind1)
);
}
}
↓
function appendNode(node) {
const lastNode = getLast(nodes);
if (lastNode && lastNode.type === "word") {
// Most important change: remove adding space
if (
!isBetween(KIND_NON_CJK, KIND_CJK_PUNCTUATION) &&
!isBetween(KIND_CJK_PUNCTUATION, KIND_NON_CJK) &&
// disallow leading/trailing full-width whitespace
![lastNode.value, node.value].some((value) => /\u3000/.test(value))
) {
nodes.push({ type: "whitespace", value: "" });
}
}
nodes.push(node);
function isBetween(kind1, kind2) {
return lastNode.kind === kind1 && node.kind === kind2;
}
}
犯人は、nodes.push({ type: "whitespace", value: " " });
です。このvalue: " "
をvalue: ""
とし、最適化を加えれば、解決します。
このappendNode
関数を利用しているsplitText
関数、そしてそれを利用しているgenericPrint
(printer-markdown.js
内)・splitTextIntoSentences
→preprocess
関数(preprocess.js
)にも影響が及ぶため、消さずに残しています。これらの関数に関係がないexport
されている処理は消し、本家ソースからrequire
しています。
preprocess
・genericPrint
関数はexport
対象です。両者ともにprinter-markdown.js
でエクスポートされます。printer-markdown.js
でエクスポートされる関数のうち、これら以外は、本家からの流用で問題ありませんでした。index.js
のプラグイン定義で、このプリンタ(printer-markdown.js
のエクスポート内容)を使えば理論上動く。そう思っていました。
俺の作った処理を優先させろこのポンコツフォーマッタめ
しかし、いざテストしてみると、プリンタが公式のぶっ壊れのものが使われてしまう。Issueを投稿しようと思ったら、やはり先客はいました。
それに対するPRに対してマージされないことに文句を言ったら「コメントよく読めよ、あとパーサ新しく追加しろ」(DeepL経由でコメント読むことすらすっ飛ばして斜め読みしすぎたのでこちらが悪かった)とのこと。
サフィックスに-nocjsp
を追加したパーサを追加(処理自体はそのまま、改良プリンタを使うよう設定)したら、期待通りになりました。
index.js
の変更後箇所を抜き出してみます。まず、parsers
の値を変更しました。
const languages = [
createLanguage(require("linguist-languages/data/Markdown.json"), (data) => ({
since: "1.8.0",
parsers: ["markdown-nocjsp"],
vscodeLanguageIds: ["markdown"],
filenames: data.filenames.concat(["README"]),
extensions: data.extensions.filter((extension) => extension !== ".mdx"),
})),
createLanguage(require("linguist-languages/data/Markdown.json"), () => ({
name: "MDX",
since: "1.15.0",
parsers: ["mdx-nocjsp"],
vscodeLanguageIds: ["mdx"],
filenames: [],
extensions: [".mdx"],
})),
];
次に、定義したプリンタを使うような、新しい名前のASTフォーマットを定義し、それを新しいパーサで使うようにしています。
const printers = {
"mdast-nocjsp": printer,
};
const baseParsers = require("./prettier/src/language-markdown/parser-markdown")
.parsers;
const modifiedParsers = Object.fromEntries(
Object.entries(baseParsers).map(([lang, parser]) => [
lang,
{ ...parser, astFormat: parser.astFormat + "-nocjsp" },
])
);
あとは、新しいパーサ名でオレオレパーサを使うよう設定しました。
const parsers = {
/* istanbul ignore next */
get "remark-nocjsp"() {
return modifiedParsers.remark;
},
get "markdown-nocjsp"() {
return modifiedParsers.remark;
},
get "mdx-nocjsp"() {
return modifiedParsers.mdx;
},
};
GitHubに公開
これでまともに動く状態になったので、GitHubに上げました。
Markdown内のMarkdownが反映されない
これで完成と思いきや、問題が残っていました。Markdownの中にコードブロックを作り、Markdownを書くと、公式のパーサが使われてしまいます。
Markdownの中のMarkdownにスペースが挿入されてしまう
```markdown
Markdownの中にMarkdown
```
調査の結果、言語名からパーサオブジェクトを動的に呼び出す処理があったので、ちょっかいを出して、Markdown関連の言語名に対しては拙作のものを使わせるようにしました。これで正しくフォーマットされます。
犯人はembed.js
内のembed
関数です。以下の処理により、「```
」直後の言語名(node.lang
)をパーサオブジェクトに変換しています。
const parser = inferParserByLanguage(node.lang, options);
Markdown系言語名が与えられたときだけ、自前パーサを使わせたいので、この言語名をこの関数に渡す直前に変更します。こんな関数を定義しました。
// この手法はダメでした。後述
function modifyLanguage(lang) {
switch (lang) {
case "markdown":
case "mdx":
case "remark":
return lang + "-nocjsp";
default:
return lang;
}
}
これを利用し、該当箇所を次のように変更しました。
const parser = inferParserByLanguage(modifyLanguage(node.lang), options);
(バージョン1.4.0で修正)Jestでテストを追加したところ、上の手法だとパーサが見つからないことが発覚。options.plugins
の中に候補パーサ一覧があり、inferParserByLanguage
がその中から言語名に当てはまるパーサを探しているようなので、以下の関数を定義して組み込みMarkdownプラグインをoptions
から削除するようにしました。
function removeBuiltInMarkdownPlugin(options) {
const newPlugins = options.plugins.filter(
(plugin) =>
!(
plugin.languages.some(
(language) => language.name.toLowerCase() === "markdown"
) &&
Object.keys(plugin.printers).every(
(printerName) => !printerName.endsWith("-nocjsp")
)
)
);
return { ...options, plugins: newPlugins };
}
バンドル
いちいち使う前にyarn install
・yarn build
させるのも面倒なので、バンドルは必須です。今後TypeScript等を使うならなおさらです。
当初はnccを検討していましたが、依存しているパッケージが動的require
に対応していなかったので、Rollupを使うことになりました。
そのパッケージの作者が例の犯人ってどういうことだよ、結構重要なプラグインだからあんまり悪く言えないけどさ
npm童貞を奪われるの巻
npmからインストールしたプラグインについては、Prettierの--plugin
オプションが免除される(--plugin プラグインのルートディレクトリかjsファイルへのパス
が通常必要)ので、npmにアップロードすることにしました。
今までnpmにライブラリを公開したことはなかったので、これが初体験でした。バンドルされたスクリプトが添付されなかったり、デプロイ用のCIがコケたりで大変でしたが、無事公開できました。
https://www.npmjs.com/package/prettier-plugin-md-nocjsp
これでこの問題はまずまず緩和できたと思います。これで時間が稼げる。めでたしめでたし・・・まではいかない。
最後に
3.0でPrettier本体でも修正されるそうですが、それまではこのプラグインで我慢しましょう。
ていうかこいつの逆のことするプラグインさっさと作ってメンテしろや犯人め。