在 Markdown 中使用 Shadow DOM 構建模式庫
已發表: 2022-03-10我使用桌面文字處理器的典型工作流程是這樣的:
- 選擇一些我想複製到文檔另一部分的文本。
- 請注意,應用程序的選擇比我告訴它的要多或少。
- 再試一次。
- 放棄並決定稍後添加我預期選擇的缺失部分(或刪除額外部分)。
- 複製並粘貼選擇。
- 請注意,粘貼文本的格式與原始文本有些不同。
- 嘗試找到與原始文本匹配的樣式預設。
- 嘗試應用預設。
- 放棄並手動應用字體系列和大小。
- 注意粘貼文本上方的空白太多,按“Backspace”關閉空白。
- 請注意,有問題的文本一次提升了幾行,加入了上面的標題文本並採用了它的樣式。
- 思考我的死亡。
在編寫技術網絡文檔(閱讀:模式庫)時,文字處理器不僅不聽話,而且不合適。 理想情況下,我想要一種允許我包含內聯文檔的組件的編寫模式,除非文檔本身由 HTML、CSS 和 JavaScript 組成,否則這是不可能的。 在本文中,我將分享一種在短代碼和影子 DOM 封裝的幫助下,在 Markdown 中輕鬆包含代碼演示的方法。

CSS 和 Markdown
說出你對 CSS 的看法,但它肯定是比市場上任何 WYSIWYG 編輯器或文字處理器更一致和可靠的排版工具。 為什麼? 因為沒有高級黑盒算法試圖猜測你真正打算去哪裡的風格。 相反,它非常明確:您定義哪些元素在哪些情況下採用哪些樣式,並且它遵守這些規則。
CSS 唯一的問題是它需要你編寫它的對應物 HTML。 即使是 HTML 的偉大愛好者也可能會承認,當您只想製作散文內容時,手動編寫它是一項艱鉅的任務。 這就是 Markdown 的用武之地。憑藉其簡潔的語法和減少的功能集,它提供了一種易於學習的編寫模式,但一旦以編程方式轉換為 HTML,仍然可以利用 CSS 強大且可預測的排版功能。 它成為靜態網站生成器和現代博客平台(如 Ghost)的實際格式是有原因的。
如果需要更複雜的定制標記,大多數 Markdown 解析器將接受輸入中的原始 HTML。 然而,一個人越依賴於復雜的標記,一個人的創作系統就越難被那些技術性較差的人或那些缺乏時間和耐心的人所接受。 這就是短代碼的用武之地。
Hugo 中的簡碼
Hugo 是一個用 Go 編寫的靜態站點生成器,Go 是一種由 Google 開發的多用途編譯語言。 由於並發性(毫無疑問,還有我不完全理解的其他低級語言特性),Go 使 Hugo 成為了靜態 Web 內容的快速生成器。 這是雨果被選為新版 Smashing Magazine 的眾多原因之一。
除了性能之外,它的工作方式與您可能已經熟悉的基於 Ruby 和 Node.js 的生成器類似:通過模板處理的 Markdown 和元數據(YAML 或 TOML)。 Sara Soueidan 寫了一篇關於 Hugo 核心功能的優秀入門書。
對我來說,Hugo 的殺手級功能是它的簡碼實現。 那些來自 WordPress 的人可能已經熟悉這個概念:一種縮短的語法,主要用於包含第三方服務的複雜嵌入代碼。 例如,WordPress 包含一個 Vimeo 短代碼,它只獲取相關 Vimeo 視頻的 ID。
[vimeo 44633289]
方括號表示其內容應作為短代碼處理,並在解析內容時擴展為完整的 HTML 嵌入標記。
利用 Go 模板函數,Hugo 提供了一個非常簡單的 API 來創建自定義簡碼。 例如,我創建了一個簡單的 Codepen 短代碼以包含在我的 Markdown 內容中:
Some Markdown content before the shortcode. Aliquam sodales rhoncus dui, sed congue velit semper ut. Class aptent taciti sociosqu ad litora torquent. {{<codePen VpVNKW>}} Some Markdown content after the shortcode. Nulla vel magna sit amet dui lobortis commodo vitae vel nulla sit amet ante hendrerit tempus.
Hugo 會在編譯期間自動在shortcodes
子文件夾中查找名為codePen.html
的模板來解析 shortcode。 我的實現如下所示:
{{ if .Site.Params.codePenUser }} <iframe height='300' scrolling='no' title="code demonstration with codePen" src='//codepen.io/{{ .Site.Params.codepenUser | lower }}/embed/{{ .Get 0 }}/?height=265&theme-id=dark&default-tab=result,result&embed-version=2' frameborder='no' allowtransparency='true' allowfullscreen='true'> <div> <a href="//codepen.io/{{ .Site.Params.codePenUser | lower }}/pen/{{ .Get 0 }}">See the demo on codePen</a> </div> </iframe> {{ else }} <p class="site-error"><strong>Site error:</strong> The <code>codePenUser</code> param has not been set in <code>config.toml</code></p> {{ end }}
為了更好地了解 Go 模板包的工作原理,您需要參考 Hugo 的“Go Template Primer”。 同時,請注意以下事項:
- 它非常醜陋但功能強大。
-
{{ .Get 0 }}
部分用於檢索提供的第一個(在本例中是唯一的)參數——Codepen ID。 Hugo 還支持命名參數,它們像 HTML 屬性一樣提供。 -
.
語法是指當前上下文。 因此,.Get 0
表示“獲取為當前簡碼提供的第一個參數”。
無論如何,我認為短代碼是自脆餅以來最好的東西,而 Hugo 編寫自定義短代碼的實現令人印象深刻。 我應該從我的研究中註意到,可以使用 Jekyll 包含來達到類似的效果,但我發現它們不太靈活和強大。
沒有第三方的代碼演示
我有很多時間使用 Codepen(以及其他可用的代碼遊樂場),但是在模式庫中包含此類內容存在固有問題:
- 它使用 API,因此無法輕鬆或有效地離線工作。
- 它不僅僅代表模式或組件; 它是自己的複雜界面,包裹在自己的品牌中。 當焦點應該放在組件上時,這會產生不必要的噪音和乾擾。
有一段時間,我嘗試使用自己的 iframe 嵌入組件演示。 我會將 iframe 指向包含演示的本地文件作為其自己的網頁。 通過使用 iframe,我能夠在不依賴第三方的情況下封裝樣式和行為。
不幸的是,iframe 相當笨重且難以動態調整大小。 就創作複雜性而言,它還需要維護單獨的文件並必須鏈接到它們。 我更願意將我的組件編寫到位,包括使它們工作所需的代碼。 我希望能夠在編寫文檔時編寫演示。
demo
簡碼
幸運的是,Hugo 允許您創建包含開始和結束簡碼標籤之間內容的簡碼。 使用{{ .Inner }}
可以在短代碼文件中找到內容。 所以,假設我要使用這樣的demo
短代碼:
{{<demo>}} This is the content! {{</demo>}}
“這就是內容!” 將在解析它的demo.html
模板中以{{ .Inner }}
的形式提供。 這是支持內聯代碼演示的一個很好的起點,但我需要解決封裝問題。
樣式封裝
在封裝樣式方面,需要擔心三件事:
- 組件從父頁面繼承的樣式,
- 從組件繼承樣式的父頁面,
- 樣式在組件之間無意共享。
一種解決方案是仔細管理 CSS 選擇器,以便組件之間以及組件與頁面之間沒有重疊。 這意味著每個組件都使用深奧的選擇器,而當我可以編寫簡潔易讀的代碼時,我不會有興趣考慮。 iframe 的優點之一是默認情況下會封裝樣式,因此我可以編寫button { background: blue }
並確信它只會應用在 iframe 內。
防止組件從頁面繼承樣式的一種不太密集的方法是在選定的父元素上使用帶有initial
值的all
屬性。 我可以在demo.html
文件中設置這個元素:
<div class="demo"> {{ .Inner }} </div>
然後,我需要將all: initial
應用於此元素的實例,該實例會傳播到每個實例的子代。
.demo { all: initial }
initial
的行為是相當……特殊的。 在實踐中,所有受影響的元素都會重新採用它們的用戶代理樣式(例如display: block
for <h2>
元素)。 但是,應用它的元素class=“demo”
——需要明確恢復某些用戶代理樣式。 在我們的例子中,這只是display: block
,因為class=“demo”
是一個<div>
。
.demo { all: initial; display: block; }
注意:到目前為止,Microsoft Edge 不支持all
,但正在考慮中。 否則,支持範圍非常廣泛。 出於我們的目的, revert
值會更加健壯和可靠,但它尚未在任何地方得到支持。
Shadow DOM'ing 簡碼
使用all: initial
不會使我們的內聯組件完全不受外部影響(特異性仍然適用),但我們可以確信樣式未設置,因為我們正在處理保留的demo
類名稱。 大多數只是從低特異性選擇器(如html
和body
)繼承的樣式將被消除。
儘管如此,這只處理從父級到組件的樣式。 為了防止為組件編寫的樣式影響頁面的其他部分,我們需要使用 shadow DOM 來創建封裝的子樹。
想像一下,我想記錄一個樣式化的<button>
元素。 我希望能夠簡單地編寫如下內容,而不必擔心button
元素選擇器將應用於模式庫本身或同一庫頁面中的其他組件中的<button>
元素。

{{<demo>}} <button>My button</button> <style> button { background: blue; padding: 0.5rem 1rem; text-transform: uppercase; } </style> {{</demo>}}
訣竅是獲取短代碼模板的{{ .Inner }}
部分並將其作為新ShadowRoot
的innerHTML
包含在內。 我可能會這樣實現:
{{ $uniq := .Inner | htmlEscape | base64Encode | truncate 15 "" }} <div class="demo"></div> <script> (function() { var root = document.getElementById('demo-{{ $uniq }}'); root.attachShadow({mode: 'open'}); root.innerHTML = '{{ .Inner }}'; })(); </script>
-
$uniq
被設置為一個變量來標識組件容器。 它在一些 Go 模板函數中通過管道創建一個唯一的字符串……希望(!)——這不是一個防彈的方法; 這只是為了說明。 -
root.attachShadow
使組件容器成為影子 DOM 宿主。 - 我使用
{{ .Inner }}
填充ShadowRoot
的innerHTML
,其中包括現在封裝的 CSS。
允許 JavaScript 行為
我還想在我的組件中包含 JavaScript 行為。 起初,我認為這很容易; 不幸的是,通過innerHTML
插入的 JavaScript 不會被解析或執行。 這可以通過從<template>
元素的內容導入來解決。 我相應地修改了我的實現。
{{ $uniq := .Inner | htmlEscape | base64Encode | truncate 15 "" }} <div class="demo"></div> <template> {{ .Inner }} </template> <script> (function() { var root = document.getElementById('demo-{{ $uniq }}'); root.attachShadow({mode: 'open'}); var template = document.getElementById('template-{{ $uniq }}'); root.shadowRoot.appendChild(document.importNode(template.content, true)); })(); </script>
現在,我可以包含一個內聯演示,比如一個工作切換按鈕:
{{<demo>}} <button>My button</button> <style> button { background: blue; padding: 0.5rem 1rem; text-transform: uppercase; } [aria-pressed="true"] { box-shadow: inset 0 0 5px #000; } </style> <script> var toggle = document.querySelector('[aria-pressed]'); toggle.addEventListener('click', (e) => { let pressed = e.target.getAttribute('aria-pressed') === 'true'; e.target.setAttribute('aria-pressed', !pressed); }); </script> {{</demo>}}
注意:我已經深入地寫過關於包容性組件的切換按鈕和可訪問性。
JavaScript 封裝
令我驚訝的是,JavaScript 並沒有像 CSS 在 shadow DOM 中那樣自動封裝。 也就是說,如果在此組件的示例之前的父頁面中有另一個[aria-pressed]
按鈕,則document.querySelector
將改為定位該按鈕。
我需要的只是演示子樹的document
。 這是可以定義的,儘管非常冗長:
document.getElementById('demo-{{ $uniq }}').shadowRoot;
每當我必須針對演示容器中的元素時,我都不想編寫這個表達式。 因此,我想出了一個技巧,我將表達式分配給本地demo
變量,並通過帶有此分配的短代碼提供的前綴腳本:
if (script) { script.textContent = `(function() { var demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; ${script.textContent} })()` } root.shadowRoot.appendChild(document.importNode(template.content, true));
有了這個, demo
就相當於任何組件子樹的document
,我可以使用demo.querySelector
輕鬆定位我的切換按鈕。
var toggle = demo.querySelector('[aria-pressed]');
請注意,我已將演示的腳本內容包含在立即調用的函數表達式 (IIFE) 中,因此demo
變量——以及用於組件的所有執行變量——不在全局範圍內。 這樣, demo
可以在任何簡碼的腳本中使用,但只會引用手頭的簡碼。
在 ECMAScript6 可用的地方,可以使用“塊作用域”實現本地化,只需用大括號括住let
或const
語句。 但是,塊中的所有其他定義也必須使用let
或const
(避開var
)。
{ let demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; // Author script injected here }
影子 DOM 支持
當然,只有在支持 shadow DOM 版本 1 的情況下才能實現上述所有操作。 Chrome、Safari、Opera 和 Android 看起來都不錯,但 Firefox 和微軟瀏覽器有問題。 在attachShadow
不可用的情況下,可以檢測功能並提供錯誤消息:
if (document.head.attachShadow) { // Do shadow DOM stuff here } else { root.innerHTML = 'Shadow DOM is needed to display encapsulated demos. The browser does not have an issue with the demo code itself'; }
或者,您可以包含 Shady DOM 和 Shady CSS 擴展,這意味著一個較大的依賴項 (60 KB+) 和不同的 API。 Rob Dodson 非常友好地為我提供了一個基本演示,我很樂意分享它以幫助您入門。
組件的標題
有了基本的內聯演示功能,快速編寫與其文檔內聯的工作演示非常簡單。 這使我們能夠提出諸如“如果我想提供標題來標記演示怎麼辦?”之類的問題。 這是完全可能的,因為——如前所述——Markdown 支持原始 HTML。
<figure role="group" aria-labelledby="caption-button"> {{<demo>}} <button>My button</button> <style> button { background: blue; padding: 0.5rem 1rem; text-transform: uppercase; } </style> {{</demo>}} <figcaption>A standard button</figcaption> </figure>
然而,這個修改後的結構中唯一的新部分是標題本身的措辭。 最好提供一個簡單的接口來將其提供給輸出,從而節省我未來的自己——以及其他使用簡碼的人——的時間和精力,並降低編碼錯誤的風險。 這可以通過為短代碼提供一個命名參數來實現——在這種情況下,簡單地命名為caption
:
{{<demo caption="A standard button">}} ... demo contents here... {{</demo>}}
命名參數可以在{{ .Get "caption" }}
之類的模板中訪問,這很簡單。 我希望標題以及周圍的<figure>
和<figcaption>
是可選的。 使用if
子句,我只能在短代碼提供標題參數的情況下提供相關內容:
{{ if .Get "caption" }} <figcaption>{{ .Get "caption" }}</figcaption> {{ end }}
下面是完整的demo.html
模板現在的樣子(誠然,它有點亂,但它可以解決問題):
{{ $uniq := .Inner | htmlEscape | base64Encode | truncate 15 "" }} {{ if .Get "caption" }} <figure role="group" aria-labelledby="caption-{{ $uniq }}"> {{ end }} <div class="demo"></div> {{ if .Get "caption" }} <figcaption>{{ .Get "caption" }}</figcaption> {{ end }} {{ if .Get "caption" }} </figure> {{ end }} <template> {{ .Inner }} </template> <script> (function() { var root = document.getElementById('demo-{{ $uniq }}'); root.attachShadow({mode: 'open'}); var template = document.getElementById('template-{{ $uniq }}'); var script = template.content.querySelector('script'); if (script) { script.textContent = `(function() { var demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; ${script.textContent} })()` } root.shadowRoot.appendChild(document.importNode(template.content, true)); })(); </script>
最後一點:如果我想在標題值中支持 markdown 語法,我可以通過 Hugo 的markdownify
函數來管道它。 通過這種方式,作者能夠提供降價(和 HTML),但也不會被迫這樣做。
{{ .Get "caption" | markdownify }}
結論
就其性能和許多出色的功能而言,Hugo 目前在靜態站點生成方面非常適合我。 但我認為最引人注目的是包含短代碼。 在這種情況下,我能夠為我一直試圖解決一段時間的文檔問題創建一個簡單的界面。
與 Web 組件一樣,許多標記複雜性(有時會因調整可訪問性而加劇)可以隱藏在短代碼後面。 在這種情況下,我指的是我包含role="group"
和aria-labelledby
關係,它為<figure>
提供了更好的支持“組標籤”——不是任何人都喜歡編碼不止一次的東西,尤其是在每個實例中都需要考慮唯一的屬性值。
我相信短代碼之於 Markdown 和內容就像 Web 組件之於 HTML 和功能:一種使作者身份更容易、更可靠和更一致的方法。 我期待在這個奇怪的網絡小領域進一步發展。
資源
- 雨果文檔
- “包模板”,Go 編程語言
- “簡碼”,雨果
- “all”(CSS 簡寫屬性),Mozilla 開發者網絡
- “初始(CSS 關鍵字),Mozilla 開發者網絡
- “Shadow DOM v1:自包含的 Web 組件”,Eric Bidelman,Web Fundamentals,Google Developers
- “模板元素簡介”,Eiji Kitamura,WebComponents.org
- “包括,”傑基爾