CommonMark 的 flanking 規則會在日文與中文頁面印出 **

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

白色卡片上的黑色 Markdown 標誌,圓角矩形內含 M 與向下箭頭
本頁目錄

引言

我用三種語言發布了一篇文章,打開日文頁面想讀一讀,卻看到本該是粗體的那一句兩端排著星號。

**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 → renders
This is ** bold text** here opening run has a space → stays literal
This is **bold text ** here closing run has a space → stays literal

CommonMark 給這兩種資格各取了名字,並把每一種都寫成對分隔符號兩側字元的條件。

  • left-flanking,是指這組符號後面不是空白,而且後面不是標點,或者前面是空白或標點。
  • right-flanking,是指這組符號前面不是空白,而且前面不是標點,或者後面是空白或標點。

每一個子句的主詞都是這組符號「 ** 」本身,而「後面」指的是緊接在它之後的那一個字元。left-flanking 是開啟的資格,right-flanking 是關閉的資格。

在無法算繪的粗體裡,是哪一端失格、為什麼同一個句子寫成三種樣子,並為每一組星號標上說明。第一種的開啟端是 left-flanking 因此可以開啟,關閉端是 right-flanking 因此可以關閉,頁面顯示粗體。第二種的開啟端後面是空白,於是既不是 left-flanking 也不是 right-flanking,無法開啟;關閉端本身成立,卻沒有對象可以配對。第三種則是同樣的情況發生在關閉端。兩個壞掉的句子都連同星號一起顯示。寫下的內容頁面呈現的內容This is **bold text** here開啟端 · left-flanking ✓ · 開啟關閉端 · right-flanking ✓ · 關閉This is bold text hereThis is **␣bold text** here開啟端 · 兩者皆非 ✗ · 無法開啟關閉端 · right-flanking ✓ · 成立,但沒有對象配對This is ** bold text** hereThis is **bold text␣** here開啟端 · left-flanking ✓ · 成立,但沒有對象配對關閉端 · 兩者皆非 ✗ · 無法關閉This is **bold text ** here強調要兩個角色都到齊才成立,所以只要有一端失格,星號就會留在頁面上。
同一個句子寫成三種樣子,並為每一組符號標上說明。內側的空白只會讓其中一組失格,另一組條件仍然成立,只是失去了配對的對象。這樣就足以讓兩端的星號留在頁面上。

一組符號要開啟強調只能靠 left-flanking,要關閉只能靠 right-flanking。文件裡其他東西一概無關:與段落無關,與配對的另一組也無關。真正有作用的只有前後各一個字元。

決定一個分隔符號的兩個字元一個 `**` 分隔符號畫在它前一個字元與後一個字元之間,下方是四種組合的表格。兩邊都是文字時可開可關。前面是標點、後面是文字時可以開啟但無法關閉,這就是在 CJK 會壞掉的組合。前面是文字、後面是標點時可以關閉但無法開啟。前面是標點、後面是空白時可以關閉。解析器讀到的東西?前一個字元**?後一個字元前 / 後可開啟可關閉文字 / 文字可可標點 / 文字可不可文字 / 標點不可可標點 / 空白不可可第二列就是 CJK 的情況:句號之後接文字的關閉端。
分隔符號兩側的兩個字元決定一切。前面是標點的關閉端,後面必須是空白或標點;如果那裡出現文字就失格。

關閉端這個情況值得慢慢讀,因為出問題的就是它。前面是標點的分隔符號要成為 right-flanking,只有在後面是空白或標點時才行。左邊標點、右邊文字這種排列,兩個子句都不滿足。

英文的句號為什麼永遠安全

英文會在句號後面寫一個空白,而這個空白是有作用的。

**It removes the lock.** This is a test.

關閉端前面是 .、後面是空白,滿足 right-flanking 的第二個子句,這組符號就關得起來。英文之所以不出問題,靠的就只是這個空白。英文句末的粗體不必特別處理就自然是這個情況,所以用英文寫作時,根本不會察覺有這條規則。

同一個句子在英文可行、在日文不行的原因同一個粗體句子並排呈現兩種語言。英文的關閉端後面有一個空白,滿足 right-flanking 條件,強調因此關閉。日文的下一句緊接著開始,關閉端後面是一個文字,於是這組符號就以星號的原樣留在頁面上。英文**It removes the lock.**␣This is…關閉端後面是空白 → 可以關閉日文**ロックを削除します。**これは…關閉端後面是文字 → 原樣留著
同一個句子並排兩種語言。英文句號後面的空白,正是讓關閉端成為 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`** です
三種會失敗的形式與各自的修法三列。以句號結尾的粗體、包住引號詞語的粗體,以及包住行內程式碼的粗體。每一種都並列失敗的樣子與修好的樣子:前兩種把標點移到強調之外,第三種因為反引號無法重排,改成在分隔符號外側加一個空白。原樣留著正常算繪…削除します。**これは…削除します**。これは句末的 。…対象を**「引用」**とする…対象を「**引用**」とする引號括弧…ではなく**`client-id`**です…ではなく **`client-id`** です行內程式碼只有第三種需要空白,前兩種是把字元移到強調之外。
三種形式與各自的修法。前兩種只是把一個字元移到強調之外;只有第三種因為反引號動不了,才退而求其次在外側加空白。

第三種修法比較難看,因為中文字和粗體之間會看得到一個空白。但當分隔符號緊貼著一個動不了的反引號時,也只有這個辦法。

四次掃描,以及弄壞三個段落的那一次

手工一個個修不切實際,所以我寫了掃描腳本來處理:遇到失格的關閉端,就把句末標點移到強調外面;遇到失格的開啟端,就把成對的括號移到外面;兩者都不適用時,才退而在外側補一個空白。

出錯的是第三次掃描。我把分隔符號逐行配對,而只要段落有折行,這是錯的。

…把同一段流程分別跑在釋出版與修正版的打包檔上,**組字中的狀態完全相同,
結果卻不同**:

只讀第二行的話,那組 ** 是該行第一個出現的,所以逐行處理會判定它是開啟端,但它其實是關閉端。掃描於是拿 left-flanking 的條件去檢查它,發現不合格,就在它前面插入一個空白,而那個空白正好會讓關閉端不再是 right-flanking。

逐行配對會把關閉端讀成開啟端一個段落折成兩行原始碼,粗體的分隔符號在第一行開啟、第二行關閉。逐行讀取時,第二行上的那一個是該行首次出現的分隔符號,於是被當成開啟端,修正流程便在它前面加上空白而弄壞它。以段落為單位讀取時,它是第二個分隔符號,會被正確當成關閉端。一個段落,折成兩行…打包檔上,**組字中的狀態完全相同,結果卻不同**:第 1 個 · 開啟逐行配對第 1 個 · 開啟錯:會在它前面加上一個空白結果卻不同␣**:以段落為單位配對第 2 個 · 關閉對:當成關閉端判定,原樣保留結果卻不同**:
同一個關閉端,用兩種方式讀。逐行配對會讓它變成第一組,因而被當成開啟端;以段落為單位配對則讓它維持第二組,也就是它本來的角色。

一個修補流程如果可能製造出新的缺陷,就得拿同一套檢查去檢查它自己的輸出。那三個被我弄壞的段落,是後來把所有檔案重新丟給算繪器跑一遍才發現的,第四次掃描才把它們修了回來。

現在擋住它的檢查

規則是兩個字元的函式,所以檢查本身並不複雜。它以段落為單位配對分隔符號,套用兩種 flanking 判定,並把該開啟時開不了、該關閉時關不了的符號都報出來。

scripts/prose-check.ts
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,英文產生不出這種形式。

總結

  1. 一組 ** 要開啟強調只能靠 left-flanking,要關閉只能靠 right-flanking,而兩種判定都只看前後各一個字元。
  2. 前面是標點的關閉端,後面必須是空白或標點,所以 。**これは 既開不了也關不了,最後就以星號的原樣出貨。
  3. 英文每個句號後面都會寫空白,因此碰不到這個條件。這個缺陷在來源語系裡看不見,一到翻譯版本就會系統性地出現。
  4. 修法是把標點移到強調之外;分隔符號緊貼反引號時,外側的空白是退路。
  5. 分隔符號要以段落為單位配對。折行的段落可能讓關閉端出現在行首,而逐行處理會把它讀成開啟端。
  6. 這條規則只有兩個字元寬,所以它值得交給機器檢查,而不是留給人工校閱。

參考連結

分享這篇文章