個人開発をしています。ちなみにコードはほとんど自分では書いていなくて、AIと一緒に作っています。この記事で扱うのも、AIに記事を投稿させていて踏んだバグです。
今日、書いた記事をブログに貼ったら、1箇所だけ太字にならず ** という記号がそのまま本文に出ていました。
ココナラの出品者手数料は**22%**です。
これがそのまま ココナラの出品者手数料は**22%**です。 と表示されたんです。
同じ記事の中に太字は50箇所以上あって、そこだけが失敗していました。 サービス側のバグを疑ったんですが、調べたらMarkdownの仕様どおりの動きでした。
初心者の方でも追えるように書きます。
まず再現する
CommonMark(Markdownの標準仕様)の公式実装で試した結果がこれです。
| 書いたもの | 太字になるか |
|---|---|
価格は**100円**です |
⭕ なる |
手数料は**22%**です |
❌ ならない |
終わり**(完)**でした |
❌ ならない |
A **22%** B |
⭕ なる |
手数料は**22%です** |
⭕ なる |
100円 は通るのに 22% は通らない。ここが最初まったく分かりませんでした。
違いは閉じる ** の直前が何かです。
-
100**円**… 直前は「円」= 文字 -
22%**… 直前は「%」= 記号
なぜ記号だとダメなのか
Markdownには「** が開き記号なのか閉じ記号なのか」を判定するルールがあります。
同じ ** でも、文章の中では
これは**強調**です
の1個目が「開く」で2個目が「閉じる」ですよね。パーサ(Markdownを読み取るプログラム)は、これを前後の文字を見て判定しています。
CommonMarkでは、閉じ記号として使えるかどうかをこう決めています。
直前が空白でない。かつ、
(a) 直前が記号でない、または (b) 直前が記号で、直後が空白か記号
22%**です を当てはめます。
- 直前が空白でない → ✅ OK
- (a) 直前は
%なので記号。→ ✗ - (b) 直後は「で」なので、空白でも記号でもない。→ ✗
(a)も(b)も満たさないので、閉じ記号として認められません。 閉じられないので、開き記号のほうも「ただの ** という文字」に戻ります。だから **22%** が丸ごと本文に出た、というわけです。
なぜ「直後」まで見るのか
(b)の条件、つまり「直前が記号でも、直後が空白か記号なら通す」がポイントです。
普通の文章では、太字は文の切れ目で閉じます。
そこで**終わり**。ですが
これは通ります。閉じる ** の直前が「り」=文字なので、(a)だけで成立しています。
そして直前が記号でも、閉じたあとに区切りがあれば通ります。
手数料は**22%**。です ⭕ 直後が「。」=記号
手数料は**22%** です ⭕ 直後が空白
つまりパーサは、**「ここで終わりだと分かる形になっているか」**を見ています。記号にも文字にも挟まれて、区切りが1つも無い状態(%**で)だと、閉じ記号なのか本文の ** なのか判断できない。だから通していない、ということですね。
英語だと単語が空白で区切られるのでこの条件はほとんど問題になりません。日本語で踏みやすいのは、空白を入れずに文章がくっつくからです。
日本語だとどこで踏むか
閉じる ** の直前が記号になるパターンは、日本語だとけっこうあります。以下は全部ダメなほうです。
手数料は**22%**です ← %
つまり**(1)**が正解です ← )
これが**「本命」**でした ← 」
最後に**(完)**でした ← )
逆に、閉じ記号の直後が「。」や「、」や空白なら通ります。だから文末は平気で、文の途中で記号のあとに閉じたときだけ落ちる。これが「50箇所中1箇所だけ失敗」の正体でした。
直し方
1. 太字の範囲を1文字ずらす(おすすめ)
❌ 手数料は**22%**です
⭕ 手数料は**22%です**
一番かんたんです。見た目の差もほとんどありません。
2. 記号を文字に書き換える
⭕ 手数料は**22パーセント**です
3. 前後に空白を入れる
⭕ 手数料は **22%** です
直前が空白になるので通ります。ただし日本語のあいだに半角空白が入って見えるので、好みが分かれます。
4. HTMLで書く
手数料は<strong>22%</strong>です
確実ですが、対応していないサービスもあります。
気づくためにやったこと
この手のバグは、目で読んでも見落とします。 5,000文字の記事の中の1箇所です。
やったのはこれだけでした。
// 変換後の本文に ** が残っていないか数える
(document.body.innerText.match(/\*\*/g) || []).length
0でなければ変換に失敗しています。残っている行を出すのも1行です。
document.body.innerText.split('\n')
.map((line, i) => [i, line])
.filter(([i, line]) => line.includes('**'))
「目で確認する」を「数える」に置き換えると、こういう見落としが消えます。自分は記事を貼ったあと必ずこれを走らせるようにしました。
まとめ
- Markdownの
**は、閉じる位置の直前が記号だと閉じ記号として無効になる - そのため
**22%****(完)****「本命」**などは太字にならない - 直後が「。」や空白なら通るので、文末では起きにくく、文の途中でだけ落ちる
- 直し方は太字の範囲を1文字ずらすのが一番ラク(
**22%です**) - 見つけるには、変換後に
**が残っていないかを数える
サービス側のバグだと思って調べ始めたので、仕様だと分かったときはちょっとスッキリしました。同じところで止まった人の役に立てば嬉しいです。
参考
※ 動作確認は CommonMark の公式実装(commonmark.js)で行いました。実際に踏んだのは note のエディタです。お使いのサービスで再現するかは、この記事の表にある5パターンを貼ってみれば1分で確かめられます。