MOOSSin
選單

全部文章 · 開源工具

markdown-it-py,還是自己寫一個只認白名單的渲染器

markdown-it-py 是 CommonMark 相容、可掛外掛的成熟函式庫。但如果你的 CSP 不允許內嵌腳本,內容又來自你不完全控制的管線,一個兩百行、只認白名單語法、每個值只跳脫一次的渲染器可能更合適。這篇講兩條路各自的條件,從一個「星號到底會不會變粗體」的爭執開始。

我跟朋友吵過一次架,主題是 Markdown。她說兩個星號包起來一定會變粗體,我說要看你用哪個渲染器。她說 Markdown 就是 Markdown,哪有分。我說 Markdown 有分,而且分得很細,細到有一份叫 CommonMark 的規範,幾百頁,專門在講「兩個星號在什麼情況下會變粗體」。她說我在找碴。

我沒有在找碴。把 Markdown 變成 HTML 這件事,看起來簡單,實際上是一個充滿邊角案例的世界。Python 上最省事的答案是 markdown-it-py:JavaScript 圈 markdown-it 的移植,MIT 授權,通過 CommonMark 規範測試,外掛透過 mdit-py-plugins 掛進來,表格、腳註、數學都有現成的。我的一個專案用它,另一個專案卻自己寫了一個渲染器。這篇講什麼條件下該走哪一條。

markdown-it-py 的用法和一個注意事項

MarkdownIt().render(text) 就是全部。它有幾個預設組態可選,commonmark 最嚴格,zero 什麼都不開讓你逐項啟用。表格要另外開,因為表格不在 CommonMark 規範裡,它是 GitHub 那一派加上去的。這也是我跟朋友吵架的根源之一:她以為的 Markdown 是 GitHub 的 Markdown,我以為的是規範的 Markdown,兩個都對,也都不對。

要注意的是 HTML 直通。Markdown 規範允許在文件裡直接寫 HTML,渲染器如果原樣輸出,使用者就能塞進一段 script 標籤,然後你的網站就變成別人的了。內容來源不是你自己時,把 HTML 選項關掉,或者在輸出之後再過一次消毒器,兩者擇一,最好兩者都做。這件事教學文章通常放在最後一段,字很小,但它是整篇最重要的一段。

什麼條件下我會自己寫

我有一個網站的 CSP 是 script-src 'self',沒有 'unsafe-inline',任何內嵌腳本都會被瀏覽器擋掉,連結構化資料常用的 JSON-LD 區塊都不能用,改用 microdata 屬性。內容經過 AI 和訊息軟體才到後台,不是我逐字寫的。

在這個前提下,我對渲染器的要求變得很具體:輸出裡只能有我明確允許的標籤,每一段文字只跳脫一次,連結的協定在白名單內,圖片只能是站內的。一個功能完整的函式庫也做得到,但我得讀懂它的所有選項和外掛,並且相信下一個版本不改預設值。兩百行自己寫的程式,我讀得完,測試也寫得完。

這有點像自己煮和外食的差別。外食方便、選擇多、通常也不會出事。但如果你對某個東西過敏,而且過敏反應很嚴重,你會想知道鍋子裡的每一樣東西是什麼。自己煮不是因為你煮得比較好吃,是因為你需要知道鍋子裡有什麼。

一個嚴格子集渲染器該做什麼

認得的語法要少而明確:標題、段落、清單、引用、表格、圍欄程式碼、水平線,行內的粗體、斜體、程式碼和連結。就這些。其他語法一律當可見文字輸出,不解釋也不吃掉,作者看到就知道那個語法不支援。「看到」很重要,默默吃掉的東西作者永遠不會發現。

拒絕的東西寫成一張表:script、iframe、object、embed、style、link、meta、svg、form、input、textarea、button 這些標籤出現就拒收,on 開頭的事件屬性也拒收。連結只允許 http、https、mailto、tel 和相對路徑,javascript: 和 data: 拒絕,反斜線和控制字元拒絕。這些檢查在寫入時就跑,不合格的稿子退回給作者並說明是哪一條,而不是等到渲染時默默拿掉。

跳脫只做一次。先解析原文,再對每個值 html.escape 一次,絕對不對已經產生的標籤或屬性再跑一次 Markdown 替換。雙重處理是 XSS 最常見的來源之一,而且是那種你看程式碼會覺得「這樣寫很合理啊」的來源。

小劇場:星號之爭的後續

「所以你自己寫的那個,兩個星號會變粗體嗎?」

「會。」

「一個星號呢?」

「斜體。」

「三個呢?」

「不支援,會原樣印出三個星號。」

「這不是很爛嗎?」

「這是刻意的。我寧可作者看到三個星號然後改掉,也不要渲染器自己猜作者的意思。猜錯的成本比印出星號高。」

「你真的很不信任渲染器。」

「我不信任任何我沒有讀完的程式,包括我自己三個月前寫的。」

一條跟閱讀有關的規則

頁面已經印出文章標題,正文若再以一級標題重複,畫面上就有兩個 h1。寫入時遇到正文開頭的一級標題可以退回並指出第幾行;讀取舊內容時,跟標題完全相同的那一行略過不顯示,內容不同的一級標題不刪,那是作者的文字。這條規則跟安全無關,跟「文章看起來像不像一篇文章」有關。

我的結論

內容全部出自自己、輸出環境也寬鬆時,markdown-it-py 是對的,別重造輪子,把時間拿去寫內容。內容有外部來源、CSP 又嚴格時,一個你完全讀得懂的子集渲染器,比一個你只讀過文件的完整函式庫更讓人放心。工具的選擇跟誰會往裡面丟東西有關。

至於跟朋友的那場架,最後是這樣結束的:她打開三個不同的網站,貼了同一段 Markdown,得到三種不同的結果。她看著螢幕說「好吧」。那是我贏過最沒有成就感的一次辯論。

Markdown 為什麼會有這麼多版本

Markdown 是 John Gruber 在 2004 年發表的,一個 Perl 腳本加一頁說明。那頁說明寫得很隨性,很多情況沒有講清楚,例如「兩個清單之間空幾行算同一個清單」。於是每個實作者都自己猜,猜出來的結果都不一樣,十年後同一段文字在不同網站上長得不一樣變成常態。

2014 年一群人受不了了,包括 Stack Overflow 的創辦人和一位語言學家,他們寫了 CommonMark,一份幾百頁、附六百多個測試範例的規範,把每一個模糊的地方都釘死。這件事當時還引起一點爭議,因為 Gruber 本人不太願意 Markdown 被「標準化」。但規範還是寫出來了,現在主流的渲染器都以它為基準。

GitHub 在 CommonMark 上加了表格、刪除線、任務清單、自動連結,叫 GFM。大家日常在用的「Markdown」多半是 GFM,這就是為什麼你在 GitHub 上寫的表格貼到別的地方可能變成一排豎線。markdown-it 是 JavaScript 圈最完整的 CommonMark 實作之一,markdown-it-py 是它的 Python 移植,連測試都一起搬過來,所以兩邊的行為幾乎一致。

常見的三個誤會

第一個誤會是「Markdown 是安全的」。Markdown 本身允許嵌入 HTML,渲染器如果沒關掉這個功能,它就跟直接讓使用者寫 HTML 一樣危險。安全不是 Markdown 的性質,是渲染器設定的結果。

第二個誤會是「空一行才是新段落,不空就是同一段」。在 CommonMark 裡,不空行的換行預設會被合併成同一段落,但很多聊天軟體和筆記 app 把每個換行都當新段落。同一段文字兩邊看起來不同,就是這個原因。

第三個誤會是「渲染器越寬容越好」。寬容的渲染器會「猜」作者的意思,猜對的時候很貼心,猜錯的時候作者根本不知道。嚴格的渲染器把不認得的東西原樣印出來,作者會看到、會改。對一條自動化的流程,我選後者。

一個週末可以試的小練習

寫一段 Markdown,包含一個表格、一段內嵌 HTML(隨便一個 <b> 標籤)、三個星號包起來的字、一個沒有空行的換行。貼到三個地方:GitHub 的 issue 預覽、你常用的筆記 app、以及 pip install markdown-it-py 之後在 Python 裡用預設設定 render 一次。

把三個結果並排比較。你會看到表格在某處變成豎線、HTML 在某處被原樣印出、三個星號在某處變成粗斜體在某處只有粗體。這二十分鐘會讓你永遠記得「Markdown 有分」。然後如果你想再進一步,在 markdown-it-py 裡試試 MarkdownIt("zero"),什麼都不開,看看一段 Markdown 在完全沒有規則的情況下長什麼樣。

後記

跟朋友吵完那次之後,我們有一個默契:講到 Markdown 的時候先說「你說的是哪一種」。這句話後來擴散到很多別的話題,例如「你說的 JSON 是嚴格的還是可以有註解的」。很多爭執其實不是意見不同,是兩個人在講不同的東西而不自知。這大概是那場星號之爭留給我最有用的東西,比 CommonMark 規範還有用。

專案頁面:github.com/executablebooks/markdown-it-py

閱讀偏好

返回全部文章作者與內容更正

留言(0)

還沒有留言。讀完有想法,歡迎留下第一則。

留言經站長審核後才會顯示。暱稱和留言會公開,請不要寫下電話、地址等個人資料;資料怎麼處理,請見隱私權政策。