CommonMark 的 flanking 規則會在日文與中文頁面印出 **
CommonMark 只有在 right-flanking 時才讓 ** 關閉強調。日文與中文在句號後面沒有空白,於是粗體就以星號的原樣出現在頁面上。

本頁目錄
引言
我用三種語言發布了一篇文章,打開日文頁面想讀一讀,卻看到本該是粗體的那一句兩端排著星號。
**SIGTERM を受け取った Chrome は、終了する際に自分の SingletonLock を削除します。**これはテスト用に…原因出在 CommonMark。** 這組分隔符號只有在 right-flanking 時才能關閉並強調為粗體。夾在句號與文字之間的分隔符號,既不是 left-flanking 也不是 right-flanking。英文不會出現這種情況,因為每個句號後面都跟著一個空白。CJK (中文、日文、韓文)沒有這個空白。
決定一組 ** 能不能關閉的東西
一串星號不是一道指令。它只是個候選,兩側的那兩個字元決定這個候選可以變成什麼。
left-flanking 與 right-flanking
粗體像括號一樣,是由一對分隔符號框起來的。一組負責開啟強調,另一組負責關閉,而解析器在配對之前,會先判定每一組符合哪一種角色的資格。
兩種角色都不符合的分隔符號不是錯誤,它會以兩個星號的原樣留在頁面上。
資格是逐組判定的,判定的依據只有緊鄰的字元。一組符號能否開啟,看的是它緊貼著它要開啟的文字;能否關閉,看的是它緊貼著它要關閉的文字。內側只要有一個空白,這個判定就失效了。
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 給這兩種資格各取了名字,並把每一種都寫成對分隔符號兩側字元的條件。
- left-flanking,是指這組符號後面不是空白,而且後面不是標點,或者前面是空白或標點。
- right-flanking,是指這組符號前面不是空白,而且前面不是標點,或者後面是空白或標點。
每一個子句的主詞都是這組符號「 ** 」本身,而「後面」指的是緊接在它之後的那一個字元。left-flanking 是開啟的資格,right-flanking 是關閉的資格。
一組符號要開啟強調只能靠 left-flanking,要關閉只能靠 right-flanking。文件裡其他東西一概無關:與段落無關,與配對的另一組也無關。真正有作用的只有前後各一個字元。
關閉端這個情況值得慢慢讀,因為出問題的就是它。前面是標點的分隔符號要成為 right-flanking,只有在後面是空白或標點時才行。左邊標點、右邊文字這種排列,兩個子句都不滿足。
英文的句號為什麼永遠安全
英文會在句號後面寫一個空白,而這個空白是有作用的。
**It removes the lock.** This is a test.關閉端前面是 .、後面是空白,滿足 right-flanking 的第二個子句,這組符號就關得起來。英文之所以不出問題,靠的就只是這個空白。英文句末的粗體不必特別處理就自然是這個情況,所以用英文寫作時,根本不會察覺有這條規則。
日文與中文的句子之間不放空白。關閉端因此會緊貼著下一句的第一個字元,而那個字元幾乎都是普通文字。
在 CJK 會失敗的三種形式
CJK 技術文章裡經常出現下面三種排列,而三種都會產生一組什麼都做不了的符號。
…削除します。**これは 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 right第一種是粗體剛好在句末結束。第二種是用引號括起來的詞語,引號本身是標點,於是開啟端失格。第三種是用粗體包住行內程式碼,反引號起了同樣的作用。
每一種修法都是把標點移到強調之外,而不是在內側補一個空白。
…削除します**。これは…対象を「**引用**」とする…ではなく **`client-id`** です第三種修法比較難看,因為中文字和粗體之間會看得到一個空白。但當分隔符號緊貼著一個動不了的反引號時,也只有這個辦法。
四次掃描,以及弄壞三個段落的那一次
手工一個個修不切實際,所以我寫了掃描腳本來處理:遇到失格的關閉端,就把句末標點移到強調外面;遇到失格的開啟端,就把成對的括號移到外面;兩者都不適用時,才退而在外側補一個空白。
出錯的是第三次掃描。我把分隔符號逐行配對,而只要段落有折行,這是錯的。
…把同一段流程分別跑在釋出版與修正版的打包檔上,**組字中的狀態完全相同,結果卻不同**:只讀第二行的話,那組 ** 是該行第一個出現的,所以逐行處理會判定它是開啟端,但它其實是關閉端。掃描於是拿 left-flanking 的條件去檢查它,發現不合格,就在它前面插入一個空白,而那個空白正好會讓關閉端不再是 right-flanking。
一個修補流程如果可能製造出新的缺陷,就得拿同一套檢查去檢查它自己的輸出。那三個被我弄壞的段落,是後來把所有檔案重新丟給算繪器跑一遍才發現的,第四次掃描才把它們修了回來。
現在擋住它的檢查
規則是兩個字元的函式,所以檢查本身並不複雜。它以段落為單位配對分隔符號,套用兩種 flanking 判定,並把該開啟時開不了、該關閉時關不了的符號都報出來。
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;有兩個細節比表面上看起來重要。段落的界線是空行,不是行號中斷的地方;否則夾在兩個程式碼區塊之間的整段文字會被當成同一個單位。
另一個是,分隔符號的數量是奇數時要回報,而不是跳過。單獨一組 ** 一定會原樣算繪出來,所以它本身就是缺陷。跳過那個範圍,後面的一切就都不再檢查,而且報告上是零缺陷。
檢查要有人親眼看過它失敗,才算得上檢查,所以我故意把一個修好的檔案弄壞。
輸入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,而兩種判定都只看前後各一個字元。 - 前面是標點的關閉端,後面必須是空白或標點,所以
。**これは既開不了也關不了,最後就以星號的原樣出貨。 - 英文每個句號後面都會寫空白,因此碰不到這個條件。這個缺陷在來源語系裡看不見,一到翻譯版本就會系統性地出現。
- 修法是把標點移到強調之外;分隔符號緊貼反引號時,外側的空白是退路。
- 分隔符號要以段落為單位配對。折行的段落可能讓關閉端出現在行首,而逐行處理會把它讀成開啟端。
- 這條規則只有兩個字元寬,所以它值得交給機器檢查,而不是留給人工校閱。



