CommonMark のフランキング規則が日本語と中国語のページに ** を出力する
CommonMark が ** で強調を閉じられるのは right-flanking のときだけです。日本語と中国語は句点のあとに空白がないため、太字がアスタリスクのままページに出ます。

目次
はじめに
3 言語で公開した記事の日本語ページを読もうとしたところ、太字になるはずだった一文の両端にアスタリスクが並んでいました。
**SIGTERM を受け取った Chrome は、終了する際に自分の SingletonLock を削除します。**これはテスト用に…原因は CommonMark 側にあります。** のランが強調を閉じられるのは right-flanking のときだけです。句点と文字に挟まれたランは left-flanking でも right-flanking でもありません。英語がこの条件に引っかからないのは、文末の句点のあとに必ず空白が入るからです。CJK にはその空白がありません。
** が閉じられるかどうかを決めるもの
アスタリスクの並びは命令ではありません。それは候補にすぎず、その候補が何になれるかは両隣の 2 文字が決めます。
left-flanking と right-flanking
太字は括弧と同じように、ペアのランで囲まれます。片方が強調を開き、もう片方が閉じるのですが、パーサーはペアを組む前に、それぞれのランがどちらの役割に適格かを判定します。
どちらの役割にも適格でないランはエラーになりません。アスタリスク 2 つのままページに残ります。
適格かどうかはランごとに、しかも隣接だけで決まります。ランが開き側になれるのは、強調したい本文にそのまま接しているときだけで、閉じ側になれるのも同じく本文に接しているときだけです。内側に空白が入ると、その接触が切れます。
This is **bold text** here both runs touch the text → rendersThis is ** bold text** here opening run has a space → stays literalThis is **bold text ** here closing run has a space → stays literalCommonMark はこの 2 つの適格性に名前を与え、それぞれをランの両隣の文字に対する条件として定めています。
- left-flanking とは、そのランの直後が空白でなく、かつ「直後が約物でない」か「直前が空白か約物」のどちらかが成り立つことです。
- right-flanking とは、そのランの直前が空白でなく、かつ「直前が約物でない」か「直後が空白か約物」のどちらかが成り立つことです。
どの節も主語はランそのものであり、「直後」とはすぐ後ろにある 1 文字を指します。left-flanking は開く適格性、right-flanking は閉じる適格性です。
ランが強調を開けるのは left-flanking のときだけ、閉じられるのは right-flanking のときだけです。ドキュメントの他の要素は一切関係しません。段落も、対になるランも関係なく、効くのは直前と直後の 1 文字だけです。
閉じ側のケースは、実際に牙をむくところなので丁寧に追ってください。直前が約物のランが right-flanking になるのは、直後が空白か約物のときだけです。左が約物で右が文字という並びは、どちらの節も満たしません。
英語の句点がつねに安全な理由
英語は句点のあとに空白を書きます。この空白が効いています。
**It removes the lock.** This is a test.閉じ側のランは直前が . で直後が空白なので、right-flanking の 2 つ目の節を満たし、ランは閉じます。英語で問題が起きないのは、この空白があるからにすぎません。英語の文末の太字は放っておいてもこの形になるため、英語で書いているかぎりこの規則は目に入りません。
日本語と中国語は文と文のあいだに空白を置きません。そのため閉じ側のランは次の文の 1 文字目に直接くっつき、そこに来るのはたいてい約物ではない普通の文字です。
CJK で失敗する 3 つの形
CJK の技術文書では次の 3 つの並びが頻繁に出てきて、いずれも開くことも閉じることもできないランになります。
…削除します。**これは closing run: 。 on the left, こ on the right…対象を**「引用」**とする opening run: を on the left, 「 on the right…ではなく**`client-id`**です opening run: く on the left, ` on the right1 つ目は太字がそのまま文末で終わる形です。2 つ目はかぎ括弧で囲んだ語句で、かぎ括弧が約物なので開き側が失格になります。3 つ目はインラインコードを太字で囲んだ形で、バッククォートが同じ働きをします。
どの直し方も、内側に空白を足すのではなく、約物を強調の外に出します。
…削除します**。これは…対象を「**引用**」とする…ではなく **`client-id`** です3 つ目の直し方は見た目が悪くなります。日本語の文字と太字のあいだに空白が見えてしまうからです。それでも、動かせないバッククォートにデリミタが接している場合はこれしかありません。
4 回のスイープと、3 つの段落を壊した 1 回
手作業で直すのは現実的ではなかったので、スイープをかけました。失格になった閉じ側なら文末の約物を外に出し、失格になった開き側なら対の括弧を外に出し、どちらでもなければ空白に頼る、という処理です。
壊したのは 3 回目のスイープです。デリミタのランを行ごとに対応づけたのですが、段落が折り返されているとこれが間違いになります。
…把同一段流程分別跑在釋出版與修正版的打包檔上,**組字中的狀態完全相同,結果卻不同**:2 行目だけを読むと、その行で最初に現れる ** なので、行ごとの処理はこれを開き側と判定します。実際は閉じ側です。スイープは left-flanking かどうかを調べ、満たしていないと判断して手前に空白を挿入しました。閉じ側から right-flanking を奪うのは、まさにその空白です。
直す処理がその欠陥自体を作りうるなら、同じ検査をその出力にも走らせる必要があります。壊した 3 つの段落が見つかったのは、あとで全ファイルをレンダラーに通したからで、4 回目のスイープで元に戻しました。
いま塞いでいる検査
規則は前後 2 文字だけで決まるので、検査も短く済みます。ランを段落ごとに対応づけ、両方のフランキング判定を適用し、必要な役割を果たせないランを報告します。
const canOpen = !isSpace(next) && (!isPunct(next) || isSpace(prev) || isPunct(prev));const canClose = !isSpace(prev) && (!isPunct(prev) || isSpace(next) || isPunct(next));const closing = i % 2 === 1;if (closing ? canClose : canOpen) continue;見た目以上に効いている点が 2 つあります。段落の区切りは空行です。行番号の飛びで区切ってしまうと、コードフェンスに挟まれた地の文がまるごと 1 つの単位として扱われます。
もう 1 つは、ランの数が奇数なら飛ばさずに報告することです。単独の ** は必ずそのまま描画されるので、それ自体が欠陥です。その範囲を飛ばすと、以降のテキストは検査されないのに、報告される件数だけはきれいなままです。
失敗するところを誰も見たことがない検査は検査ではないので、直したファイルをわざと壊しました。
入力node scripts/prose-check.ts <slug> ja出力emphasis delimiters that cannot render: 1 line 184 closing …を削除します。**これはテスト用に起動した…ファイルを元に戻すと 0 件、終了コード 0 になりました。この検査が走るのは ja と zh-tw だけです。英語ではこの形を作れません。
まとめ
**のランが強調を開けるのは left-flanking のときだけ、閉じられるのは right-flanking のときだけで、どちらの判定も直前と直後の 1 文字しか見ません。- 直前が約物の閉じ側は直後に空白か約物を必要とします。そのため
。**これはは開くことも閉じることもできず、アスタリスクのまま出荷されます。 - 英語は句点のあとに必ず空白を書くので、この条件に引っかかりません。この欠陥はソース言語では見えず、翻訳先の言語で構造的に発生します。
- 直し方は約物を強調の外に出すこと。デリミタがバッククォートに接している場合の逃げ道が、外側の空白です。
- デリミタは段落ごとに対応づけること。折り返された段落は行頭に閉じ側が来ることがあり、行ごとの処理はそれを開き側と読みます。
- 規則は 2 文字ぶんの幅しかありません。だからこそ、レビューで見るのではなく機械で検査する価値があります。



