Aller au contenu principal
divagations - Retour à l'accueil

Implémentation avancée des images responsives, suite et fin

Guillaume Barbier

Temps de lecture : ~ 10 minutes

Maintenant que j'ai tous les éléments en place pour définir de bonnes images responsives et ajouter un plugin maison, il ne me reste plus qu'à écrire mon shortcode

La base

Avertissement

Les particularités des shortcodes avec le templating Liquid

Contrairement à d'autres langages de template (comme Nunjuks), Liquid n'accepte pas les paramètres "nommés", ce qui a plusieurs impacts :

  • L'ordre des paramètres doit être respecté (et donc doit être à peu près logique afin d'être facile à retenir)
  • Il vaut mieux ranger les paramètres par fréquence d'usage, en mettant les paramètres obligatoires à l’avant
  • Il faut prévoir des cas de paramètres vides ou nuls pour les paramètres facultatifs (si un autre paramètre facultatif est saisi plus loin dans la chaîne).

Parmi toutes les données que j'ai listé hier, trois doivent pouvoir être paramétrables (les autres sont des constantes ou générées par l'API du plugin Image) :

  • Le fichier source,
  • L'alternative texte (si image non-décorative)
  • La légende (si nécessaire)

La déclaration de mon shortcode "image" donne donc :

// In shortcodes/responsive-img.js

export default async function(eleventyConfig) {
  eleventyConfig.addShortcode("image", async function (src, alt, legend){
    // The shortcode's code…
  }
}

Astuce

Fonction asynchrone

Il est à noter que j'utilise ici une fonction asynchrone. La raison est que l'API image d'Eleventy est elle-même asynchrone (c'est nécessaire pour ne pas bloquer le processus pendant qu'elle récupère et converti les images requises).

Mon code peut se découper en trois parties :

  1. Définir et vérifier les données nécessaires
  2. Récupérer les données manquantes via l'API
  3. Écrire et renvoyer le HTML à intégrer

Vérifier et ajuster les paramètres

Parmi mes paramètres, le fichier source est bien entendu obligatoire, mais le alt text l'est aussi, pour deux raisons :

  • Techniquement, il n'est pas possible d'indiquer un paramètre "légende" si les paramètres précédents n'ont pas été définis
  • Pour des raisons d'accessibilité, je veux que nous nous forcions à le définir, même si c'est pour le définir comme vide.

Seul le paramètre "légende" est donc vraiment optionnel et nécessite uniquement que nous testions sa présence.

Faciliter la saisie de la source

Pour le fichier source, je n'ai pas vraiment besoin de vérifier sa validité ou sa présence : l'API s'en chargera pour moi (si la source appelée n'existe pas, l'API renvoie une erreur visible dans le terminal).

En revanche il y a une petite adaptation pour nous faire gagner du temps et améliorer la lisibilité de nos fichiers markdown : définir la racine de notre dossier image.

Comme j'ai arbitré que toutes nos images seraient cantonnées à un dossier exclu du versioning Git (/content/_src/img/), je peux définir cette racine comme une constante du shortcode. Grâce à cette racine, je n'ai à saisir dans mon shortcode que le nom du fichier (et éventuellement les sous-dossiers dans lesquels je l'ai rangé).

Information

Confession : La flemme a parlé

Pour être parfaitement honnête, ce qui a motivé ce choix à la base, c'est que dans un shortcode, Visual Studio Code (VS Code) ne me propose pas l'autocomplétion des paths (Ce qu'il fait en revanche quand je saisi le contenu d'un attribut href ou src)

Grâce à cette petite adaptation, j'ai juste à copier/coller le nom du fichier source (avec son extension, c'est mieux).

Sécuriser le alt text

Comme je le disais plus haut, j'ai fait le choix de nous auto-imposer la définition explicite du alt vide, même si c'est pour simplement préciser qu'il est vide.
J'ajoute donc une vérification impitoyable[1] au début de mon code : si le alt n'est pas fourni (donc qu'il a pour valeur undefined), alors le code crache un message d'erreur dans le terminal :

switch (alt) {
  case undefined:
    // You bet we throw an error on missing alt (alt="" works okay)
    throw new Error(`Missing \`alt\` on image shortcode from: ${src}`);
  case false: alt = ""
}

Et si la valeur est false, on la converti en chaîne vide (pour éviter les mauvaises surprises ensuite)

Enfin, avant d'insérer le texte fourni dans l'attribut j'utilise la bibliothèque entities pour le "désinfecter" avec la méthode escapeAttribute et supprimer/remplacer les caractères qui pourraient casser l'attribut.

Avertissement

Ce n'est pas une fonction magique

Cette méthode ne va pas miraculeusement nettoyer le alt fournie des balises ou des entités HTML qu'il contient pour le rendre soudainement lisible. Il se contente de supprimer ou remplacer les caractères inutilisables dans un attribut, comme les guillemets.

Appeler l'API Image

L'appel API est assez proche de celui que j'avais fait lors de ma toute première tentative.

Attention !

Faut attendre l'API, sinon… sinon !

Je l'évoquais plus haut, l'API s’exécute en asynchrone, donc quand on l'appelle, il est important de bien préciser dans le code qu'il faut attendre sa réponse avant de poursuivre l'exécution du code. Sans ça on s'expose à des p'tits problèmes…

let imgModel = await eleventyImage(src, {
  // Parameters…
})

Un autre point d'attention, c'est l'emplacement des fichiers générés. Par défaut l'API créé un dossier "img" à la racine du projet, ce qui ne m'arrange pas trop. Heureusement l'API propose des paramètres pour modifier cet emplacement… par contre j'ai eu du mal à comprendre la doc de ces options(S'ouvre dans un nouvelle fenêtre) (Je n'ai pas trouvé les exemples très explicites et ai mis du temps à comprendre les nuances entre chaque options. Un cas concret aurait pu aider.)

Au final, les propriétés à utiliser sont les suivantes :

  • outputDir sert à définir l'emplacement des fichiers générés et :
    • Il se réfère à l'emplacement du fichier de configuration d'Eleventy (et pas au dossier output défini dans celui-ci)
    • Il faut commencer le path par un point
    • Dans mon cas, cela donne le chemin suivant : ./__site/_src/img/renditions/ (j'isole les fichiers générés dans un dossier dédié, on sait jamais)
  • urlPath sert à définir le chemin qui sera utilisé pour composer l'URL des sources. Il accepte des URL relatives et absolues (ce qui peut être pratique pour les utilisateurs de CDN). Dans mon cas, c'est assez simple, je dois simplement reprendre le chemin défini précédemment mais en démarrant à la racine du site, ce qui donne : /_src/img/renditions/

Construire le HTML

Une fois la réponse de l'API obtenu, il ne reste plus qu'à construire le HTML de notre image responsive en parcourant le JSON obtenu.
Celui-ci est assez simplement découpé :

  • Une entrée par format demandé (dans notre cas "jpeg" et "webp")
  • Dans chaque entrée une liste de renditions avec tous les détails nécessaires pour les utiliser

La structure du JSON

{
  webp: [
    {
      // données d'une rendition
    },],
  jpeg: [
    {
      // données d'une rendition
    },]
}

Exemple des données pour la rendition jpeg à 720px :

{
  format: 'jpeg',
  width: 720,
  height: 529,
  url: '/_src/img/renditions/IbisRouge003-2-720w.jpeg',
  sourceType: 'image/jpeg',
  srcset: '/_src/img/renditions/IbisRouge003-2-720w.jpeg 720w',
  filename: 'IbisRouge003-2-720w.jpeg',
  outputPath: '__site/_src/img/renditions/IbisRouge003-2-720w.jpeg',
  size: 104204
},

En parcourant ce JSON, je dois extraire les informations suivantes :

  • la donnée srcset de chaque rendition (très pratique car elle contient aussi bien l’emplacement de l'image que son descripteur, le tout prêt à être inséré)
  • les données relatives à mon image par défaut (la rendition jpeg à 720px) qui me manquaient : sa hauteur et son URL

Pour ça, je créée une double boucle :

  • une boucle sur les formats obtenus
  • une fonction map sur le contenu de chaque rendition. Cette fonction sert principalement à produire une chaîne de texte srcset prête à l'insertion, mais permet aussi d'extraire les données manquantes pour l'image par défaut
for (const format in imgModel) {
let formatRenditions = imgModel[format]
let fullSrcset = formatRenditions.map(rendition => {
  if(format == fallbackFormat && rendition.width == fallbackWidth) {
    fallbackHeight = rendition.height
    fallbackUrl = rendition.url
  }
  return rendition.srcset
}).join(", ")

// Output (for webp format) : "/_src/img/renditions/IbisRouge003-2-320w.webp 320w, /_src/img/renditions/IbisRouge003-2-640w.webp 640w, /_src/img/renditions/IbisRouge003-2-720w.webp 720w, /_src/img/renditions/IbisRouge003-2-960w.webp 960w, /_src/img/renditions/IbisRouge003-2-1440w.webp 1440w"

Une fois ces chaînes obtenues, il ne reste plus qu'à générer le reste du HTML

Résultat

Un exemple d'image avec légende

Le shortcode dans mon fichier Markdown

{% image "IbisRouge003-2.jpg" "Dessin d'un oiseau, voir la légende" "Ibis rouge, croquis à l'encre de chine sur carton-liège, par Guillaume Barbier" %}

Le HTML généré

<picture>
  <source 
    type="image/webp" 
    srcset="
      /_src/img/renditions/IbisRouge003-2-320w.webp 320w, 
      /_src/img/renditions/IbisRouge003-2-640w.webp 640w, 
      /_src/img/renditions/IbisRouge003-2-720w.webp 720w, 
      /_src/img/renditions/IbisRouge003-2-960w.webp 960w, 
      /_src/img/renditions/IbisRouge003-2-1440w.webp 1440w
    " 
    sizes="(max-width: 768px) calc(100vw - 48px), 720px"
  >
  <source 
    type="image/jpeg" 
    srcset="
      /_src/img/renditions/IbisRouge003-2-320w.jpeg 320w, 
      /_src/img/renditions/IbisRouge003-2-640w.jpeg 640w, 
      /_src/img/renditions/IbisRouge003-2-720w.jpeg 720w, 
      /_src/img/renditions/IbisRouge003-2-960w.jpeg 960w, 
      /_src/img/renditions/IbisRouge003-2-1440w.jpeg 1440w
    " 
    sizes="(max-width: 768px) calc(100vw - 48px), 720px"
  >
  <img src="/_src/img/renditions/IbisRouge003-2-720w.jpeg" 
    width="720" height="529" 
    alt="Dessin d'un oiseau, voir la légende" 
    loading="lazy" decoding="async"
  >
  <figcaption>Ibis rouge, croquis à l'encre de chine sur carton-liège, par Guillaume&#160;Barbier</figcaption>
</picture>

L'image obtenue :
Dessin d'un oiseau, voir la légende

Ibis rouge, croquis à l'encre de chine sur carton-liège, par Guillaume Barbier

Le code complet du plugin

// In shortcodes/responsive-img.js
import path from "node:path";
import eleventyImage from "@11ty/eleventy-img";
import { escapeAttribute } from "entities";

export default async function(eleventyConfig) {

  const imageDefaultWidths= ["320", "640", "720", "960", "1440"]

  eleventyConfig.addShortcode("image", async function (src, alt, legend){
    switch (alt) {
      case undefined:
        // You bet we throw an error on missing alt (alt="" works okay)
        throw new Error(`Missing \`alt\` on image shortcode from: ${src}`);
      case false: alt = ""
		}
    let imgHTML = ""
    let imgPath = "./content/_src/img/"

    let imgModel = await eleventyImage(imgPath + src, {
      widths: imageDefaultWidths,
      formats: ["webp", "jpeg"],
      outputDir: "./__site/_src/img/renditions/",
      urlPath: "/_src/img/renditions/",
      filenameFormat: function (id, src, width, format, options) {
        const extension = path.extname(src);
        const name = path.basename(src, extension);
        return `${name}-${width}w.${format}`;
      }
    })

    let fallbackFormat = "jpeg"
    let fallbackWidth = "720"
    let fallbackHeight = ""
    let fallbackUrl = ""

    imgHTML += "<picture>"

    for (const format in imgModel) {
      let formatRenditions = imgModel[format]
      let fullSrcset = formatRenditions.map(rendition => {
        if(format == fallbackFormat && rendition.width == fallbackWidth) {
          fallbackHeight = rendition.height
          fallbackUrl = rendition.url
        }
        return rendition.srcset
      }).join(", ")

      imgHTML += `
      <source 
        type="image/${format}" 
        srcset="${fullSrcset}"
        sizes="(max-width: 768px) calc(100vw - 48px), 720px"
      >`
    }
    imgHTML += `
    <img src="${fallbackUrl}" 
      width="${fallbackWidth}" 
      height="${fallbackHeight}" 
      alt="${escapeAttribute(alt)}" 
      loading="lazy" decoding="async"
    >`
    if(legend) {
      imgHTML += `<figcaption>${legend}</figcaption>`
    }
    imgHTML += "</picture>"

    return imgHTML
  })
}

Information

Rappel

Avant de pouvoir utiliser le shortcode, il ne faut pas oublier d'importer ce fichier dans la configuration Eleventy, avant de l'appeler en tant que plugin dans cette même configuration.

Pour plus de détails à ce sujet, voir l'entrée de la veille : "Implémentation avancée des images responsives : Travail préparatoire"


  1. Cette vérification est inspirée d'un exemple de la doc Eleventy(S'ouvre dans un nouvelle fenêtre) et m'a bien plus ↩︎