Construire des bibliothèques de modèles avec Shadow DOM dans Markdown
Publié: 2022-03-10Mon flux de travail typique utilisant un traitement de texte de bureau ressemble à ceci :
- Sélectionnez du texte que je veux copier dans une autre partie du document.
- Notez que l'application a sélectionné un peu plus ou moins que ce que je lui ai dit.
- Réessayer.
- Abandonnez et décidez d'ajouter la partie manquante (ou de supprimer la partie supplémentaire) de ma sélection prévue plus tard.
- Copiez et collez la sélection.
- Notez que la mise en forme du texte collé est quelque peu différente de l'original.
- Essayez de trouver le style prédéfini qui correspond au texte d'origine.
- Essayez d'appliquer le préréglage.
- Abandonnez et appliquez la famille et la taille de police manuellement.
- Notez qu'il y a trop d'espace blanc au-dessus du texte collé et appuyez sur "Retour arrière" pour combler l'espace.
- Notez que le texte en question s'est élevé de plusieurs lignes à la fois, a rejoint le texte du titre au-dessus et a adopté son style.
- Réfléchissez à ma mortalité.
Lors de la rédaction de documentation Web technique (lire : bibliothèques de modèles), les traitements de texte ne sont pas seulement désobéissants, mais inappropriés. Idéalement, je veux un mode d'écriture qui me permette d'inclure les composants que je documente en ligne, et cela n'est possible que si la documentation elle-même est faite de HTML, CSS et JavaScript. Dans cet article, je partagerai une méthode pour inclure facilement des démonstrations de code dans Markdown, à l'aide de codes courts et d'encapsulation DOM fantôme.
CSS et démarquage
Dites ce que vous voulez à propos de CSS, mais c'est certainement un outil de composition plus cohérent et plus fiable que n'importe quel éditeur WYSIWYG ou traitement de texte sur le marché. Pourquoi? Parce qu'il n'y a pas d'algorithme de boîte noire de haut niveau qui essaie de deviner quels styles vous avez vraiment l'intention d'aller où. Au lieu de cela, c'est très explicite : vous définissez quels éléments prennent quels styles dans quelles circonstances, et cela respecte ces règles.
Le seul problème avec CSS est qu'il vous oblige à écrire son homologue HTML. Même les grands amateurs de HTML admettraient probablement que l'écrire manuellement est ardu lorsque vous souhaitez simplement produire du contenu en prose. C'est là qu'intervient Markdown. Avec sa syntaxe concise et son ensemble de fonctionnalités réduit, il offre un mode d'écriture facile à apprendre mais qui peut toujours, une fois converti en HTML par programmation, exploiter les fonctionnalités de composition puissantes et prévisibles de CSS. Il y a une raison pour laquelle il est devenu le format de facto pour les générateurs de sites Web statiques et les plateformes de blogs modernes telles que Ghost.
Lorsqu'un balisage plus complexe et sur mesure est requis, la plupart des analyseurs Markdown acceptent le HTML brut dans l'entrée. Cependant, plus on s'appuie sur un balisage complexe, moins son système auteur est accessible à ceux qui sont moins techniques, ou à ceux qui manquent de temps et de patience. C'est là qu'interviennent les shortcodes.
Codes courts dans Hugo
Hugo est un générateur de site statique écrit en Go, un langage compilé polyvalent développé chez Google. En raison de la concurrence (et, sans aucun doute, d'autres fonctionnalités de langage de bas niveau que je ne comprends pas parfaitement), Go fait d'Hugo un générateur ultra-rapide de contenu Web statique. C'est l'une des nombreuses raisons pour lesquelles Hugo a été choisi pour la nouvelle version de Smashing Magazine.
Performances mises à part, il fonctionne de la même manière que les générateurs basés sur Ruby et Node.js avec lesquels vous êtes peut-être déjà familier : Markdown plus les métadonnées (YAML ou TOML) traitées via des modèles. Sara Soueidan a écrit une excellente introduction aux fonctionnalités de base d'Hugo.
Pour moi, la fonctionnalité phare d'Hugo est son implémentation de codes courts. Ceux qui viennent de WordPress connaissent peut-être déjà le concept : une syntaxe raccourcie principalement utilisée pour inclure les codes d'intégration complexes de services tiers. Par exemple, WordPress inclut un shortcode Vimeo qui ne prend que l'ID de la vidéo Vimeo en question.
[vimeo 44633289]
Les crochets signifient que leur contenu doit être traité comme un shortcode et développé dans le balisage d'intégration HTML complet lorsque le contenu est analysé.
En utilisant les fonctions de modèle Go, Hugo fournit une API extrêmement simple pour créer des codes abrégés personnalisés. Par exemple, j'ai créé un simple shortcode Codepen à inclure dans mon contenu 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 recherche automatiquement un modèle nommé codePen.html
dans le sous-dossier shortcodes
pour analyser le shortcode lors de la compilation. Ma mise en œuvre ressemble à ceci:
{{ 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 }}
Pour avoir une meilleure idée du fonctionnement du package de modèles Go, vous voudrez consulter le "Go Template Primer" d'Hugo. En attendant, notez simplement ce qui suit :
- C'est assez moche mais puissant quand même.
- La partie
{{ .Get 0 }}
sert à récupérer le premier (et, dans ce cas, le seul) argument fourni - l'ID Codepen. Hugo prend également en charge les arguments nommés, qui sont fournis comme des attributs HTML. - Le
.
La syntaxe fait référence au contexte courant. Ainsi,.Get 0
signifie "Obtenir le premier argument fourni pour le shortcode actuel".
Dans tous les cas, je pense que les shortcodes sont la meilleure chose depuis les shortbread, et l'implémentation d'Hugo pour écrire des shortcodes personnalisés est impressionnante. Je devrais noter d'après mes recherches qu'il est possible d'utiliser Jekyll inclut pour un effet similaire, mais je les trouve moins flexibles et moins puissants.
Démos de code sans tiers
J'ai beaucoup de temps pour Codepen (et les autres terrains de jeux de code disponibles), mais il y a des problèmes inhérents à l'inclusion d'un tel contenu dans une bibliothèque de modèles :
- Il utilise une API et ne peut donc pas être facilement ou efficacement conçu pour fonctionner hors ligne.
- Il ne représente pas seulement le motif ou le composant ; c'est sa propre interface complexe enveloppée dans sa propre image de marque. Cela crée un bruit et une distraction inutiles lorsque l'accent doit être mis sur le composant.
Pendant un certain temps, j'ai essayé d'intégrer des démos de composants en utilisant mes propres iframes. Je pointerais l'iframe vers un fichier local contenant la démo comme sa propre page Web. En utilisant des iframes, j'ai pu encapsuler le style et le comportement sans compter sur un tiers.
Malheureusement, les iframes sont plutôt peu maniables et difficiles à redimensionner dynamiquement. En termes de complexité de création, cela implique également de conserver des fichiers séparés et d'avoir à les lier. Je préférerais écrire mes composants en place, y compris uniquement le code nécessaire pour les faire fonctionner. Je veux pouvoir écrire des démos au fur et à mesure que j'écris leur documentation.
La demo
Shortcode
Heureusement, Hugo vous permet de créer des shortcodes qui incluent du contenu entre l'ouverture et la fermeture des balises de shortcode. Le contenu est disponible dans le fichier shortcode en utilisant {{ .Inner }}
. Donc, supposons que je devais utiliser un shortcode de demo
comme celui-ci :
{{<demo>}} This is the content! {{</demo>}}
"C'est le contenu !" serait disponible en tant que {{ .Inner }}
dans le modèle demo.html
qui l'analyse. C'est un bon point de départ pour prendre en charge les démos de code en ligne, mais je dois aborder l'encapsulation.
Encapsulation de style
Lorsqu'il s'agit d'encapsuler des styles, il y a trois choses dont il faut s'inquiéter :
- les styles étant hérités par le composant de la page parent,
- la page parente héritant des styles du composant,
- les styles sont partagés involontairement entre les composants.
Une solution consiste à gérer soigneusement les sélecteurs CSS afin qu'il n'y ait pas de chevauchement entre les composants et entre les composants et la page. Cela signifierait utiliser des sélecteurs ésotériques par composant, et ce n'est pas quelque chose que je serais intéressé à avoir à considérer quand je pourrais écrire du code concis et lisible. L'un des avantages des iframes est que les styles sont encapsulés par défaut, donc je pourrais écrire button { background: blue }
et être sûr qu'il ne s'appliquerait qu'à l'intérieur de l'iframe.
Une manière moins intensive d'empêcher les composants d'hériter des styles de la page consiste à utiliser la propriété all
avec la valeur initial
sur un élément parent élu. Je peux définir cet élément dans le fichier demo.html
:
<div class="demo"> {{ .Inner }} </div>
Ensuite, je dois appliquer all: initial
aux instances de cet élément, qui se propage aux enfants de chaque instance.
.demo { all: initial }
Le comportement d' initial
est assez… idiosyncrasique. En pratique, tous les éléments concernés reviennent à adopter uniquement leurs styles d'agent utilisateur (comme display: block
pour les éléments <h2>
). Cependant, l'élément auquel il est appliqué - class=“demo”
- doit avoir explicitement rétabli certains styles d'agent utilisateur. Dans notre cas, il s'agit simplement de display: block
, puisque class=“demo”
est un <div>
.
.demo { all: initial; display: block; }
Remarque : all
n'est pas encore pris en charge dans Microsoft Edge mais est à l'étude. Le soutien est, par ailleurs, rassurant large. Pour nos besoins, la valeur de revert
serait plus robuste et fiable, mais elle n'est encore prise en charge nulle part.
Shadow DOM'ing The Shortcode
Utiliser all: initial
ne rend pas nos composants en ligne complètement à l'abri des influences extérieures (la spécificité s'applique toujours), mais nous pouvons être sûrs que les styles ne sont pas définis car nous traitons avec le nom de classe de demo
réservé. La plupart du temps, les styles hérités de sélecteurs à faible spécificité tels que html
et body
seront éliminés.
Néanmoins, cela ne traite que des styles provenant du parent dans les composants. Pour empêcher les styles écrits pour les composants d'affecter d'autres parties de la page, nous devrons utiliser le shadow DOM pour créer une sous-arborescence encapsulée.
Imaginez que je veuille documenter un élément stylé <button>
. J'aimerais pouvoir écrire simplement quelque chose comme ce qui suit, sans craindre que le sélecteur d'élément de button
ne s'applique aux éléments <button>
dans la bibliothèque de modèles elle-même ou dans d'autres composants de la même page de bibliothèque.
{{<demo>}} <button>My button</button> <style> button { background: blue; padding: 0.5rem 1rem; text-transform: uppercase; } </style> {{</demo>}}
L'astuce consiste à prendre la partie {{ .Inner }}
du modèle de shortcode et à l'inclure en tant que innerHTML
d'un nouveau ShadowRoot
. Je pourrais implémenter ceci comme ceci:
{{ $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
est défini comme une variable pour identifier le conteneur de composants. Il intègre certaines fonctions de modèle Go pour créer une chaîne unique… espérons-le (!) - ce n'est pas une méthode à l'épreuve des balles; c'est juste pour illustrer. -
root.attachShadow
fait du conteneur de composants un hôte DOM fantôme. - Je remplis le
innerHTML
duShadowRoot
en utilisant{{ .Inner }}
, qui inclut le CSS désormais encapsulé.
Autoriser le comportement JavaScript
J'aimerais également inclure le comportement JavaScript dans mes composants. Au début, je pensais que ce serait facile; Malheureusement, le JavaScript inséré via innerHTML
n'est pas analysé ni exécuté. Cela peut être résolu en important à partir du contenu d'un élément <template>
. J'ai modifié ma mise en œuvre en conséquence.
{{ $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>
Maintenant, je peux inclure une démo en ligne de, disons, un bouton bascule fonctionnel :
{{<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>}}
Remarque : J'ai écrit en profondeur sur les boutons bascule et l'accessibilité pour les composants inclusifs.
Encapsulation JavaScript
JavaScript n'est, à ma grande surprise, pas encapsulé automatiquement comme CSS dans le shadow DOM. Autrement dit, s'il y avait un autre bouton [aria-pressed]
dans la page parent avant l'exemple de ce composant, alors document.querySelector
le ciblerait à la place.
Ce dont j'ai besoin, c'est d'un équivalent à document
uniquement pour le sous-arbre de la démo. Ceci est définissable, bien que de manière assez verbeuse:
document.getElementById('demo-{{ $uniq }}').shadowRoot;
Je ne voulais pas avoir à écrire cette expression chaque fois que je devais cibler des éléments dans des conteneurs de démonstration. Donc, j'ai trouvé un hack par lequel j'ai assigné l'expression à une variable de demo
locale et des scripts préfixés fournis via le shortcode avec cette affectation :
if (script) { script.textContent = `(function() { var demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; ${script.textContent} })()` } root.shadowRoot.appendChild(document.importNode(template.content, true));
Avec cela en place, demo
devient l'équivalent de document
pour tous les sous-arbres de composants, et je peux utiliser demo.querySelector
pour cibler facilement mon bouton bascule.
var toggle = demo.querySelector('[aria-pressed]');
Notez que j'ai inclus le contenu du script de démonstration dans une expression de fonction appelée immédiatement (IIFE), de sorte que la variable de demo
- et toutes les variables de procédure utilisées pour le composant - ne soient pas dans la portée globale. De cette façon, la demo
peut être utilisée dans le script de n'importe quel shortcode mais ne fera référence qu'au shortcode en main.
Là où ECMAScript6 est disponible, il est possible de réaliser la localisation en utilisant la "portée de bloc", avec juste des accolades entourant les instructions let
ou const
. Cependant, toutes les autres définitions du bloc devraient également utiliser let
ou const
(en évitant var
).
{ let demo = document.getElementById(\'demo-{{ $uniq }}\').shadowRoot; // Author script injected here }
Prise en charge du DOM fantôme
Bien sûr, tout ce qui précède n'est possible que lorsque la version 1 du shadow DOM est prise en charge. Chrome, Safari, Opera et Android ont tous l'air plutôt bien, mais les navigateurs Firefox et Microsoft posent problème. Il est possible de détecter les fonctionnalités et de fournir un message d'erreur lorsque attachShadow
n'est pas disponible :
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'; }
Ou vous pouvez inclure Shady DOM et l'extension Shady CSS, ce qui signifie une dépendance assez importante (60 Ko +) et une API différente. Rob Dodson a eu la gentillesse de me fournir une démo de base, que je suis heureux de partager pour vous aider à démarrer.
Légendes des composants
Avec la fonctionnalité de démonstration en ligne de base en place, écrire rapidement des démos de travail en ligne avec leur documentation est heureusement simple. Cela nous offre le luxe de pouvoir poser des questions telles que : "Et si je veux fournir une légende pour étiqueter la démo ?" C'est déjà parfaitement possible puisque, comme indiqué précédemment, Markdown prend en charge le HTML brut.
<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>
Cependant, la seule partie nouvelle de cette structure modifiée est le libellé de la légende elle-même. Mieux vaut fournir une interface simple pour la fournir à la sortie, économiser mon futur moi - et toute autre personne utilisant le shortcode - du temps et des efforts et réduire le risque de fautes de frappe. Ceci est possible en fournissant un paramètre nommé au shortcode — dans ce cas, simplement nommé caption
:
{{<demo caption="A standard button">}} ... demo contents here... {{</demo>}}
Les paramètres nommés sont accessibles dans le modèle comme {{ .Get "caption" }}
, ce qui est assez simple. Je veux que la légende et, par conséquent, les <figure>
et <figcaption>
soient facultatifs. En utilisant des clauses if
, je ne peux fournir le contenu pertinent que lorsque le shortcode fournit un argument de légende :
{{ if .Get "caption" }} <figcaption>{{ .Get "caption" }}</figcaption> {{ end }}
Voici à quoi ressemble maintenant le modèle complet demo.html
(certes, c'est un peu le bordel, mais ça fait l'affaire):
{{ $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>
Une dernière remarque : si je souhaite prendre en charge la syntaxe de démarquage dans la valeur de la légende, je peux la diriger via la fonction markdownify
de Hugo. De cette façon, l'auteur est en mesure de fournir du markdown (et du HTML) mais n'est pas obligé de le faire non plus.
{{ .Get "caption" | markdownify }}
Conclusion
Pour ses performances et ses nombreuses fonctionnalités excellentes, Hugo me convient actuellement en matière de génération de sites statiques. Mais l'inclusion de shortcodes est ce que je trouve le plus convaincant. Dans ce cas, j'ai pu créer une interface simple pour un problème de documentation que j'essayais de résoudre depuis un certain temps.
Comme dans les composants Web, une grande partie de la complexité du balisage (parfois exacerbée par l'ajustement de l'accessibilité) peut être cachée derrière des shortcodes. Dans ce cas, je fais référence à mon inclusion de role="group"
et de la relation aria-labelledby
, qui fournit une "étiquette de groupe" mieux prise en charge à la <figure>
- pas des choses que quelqu'un aime coder plus d'une fois, en particulier où les valeurs d'attribut uniques doivent être prises en compte dans chaque instance.
Je crois que les shortcodes sont à Markdown et au contenu ce que les composants Web sont au HTML et à la fonctionnalité : un moyen de rendre la paternité plus facile, plus fiable et plus cohérente. J'attends avec impatience de nouvelles évolutions dans ce curieux petit domaine du web.
Ressources
- Documentation Hugo
- "Modèle de package", le langage de programmation Go
- "Codes courts", Hugo
- « all » (propriété abrégée CSS), Mozilla Developer Network
- "initial (mot clé CSS), Mozilla Developer Network
- « Shadow DOM v1 : composants Web autonomes », Eric Bidelman, Web Fundamentals, Google Developers
- "Introduction aux éléments de modèle", Eiji Kitamura, WebComponents.org
- "Comprend", Jekyll