Développement d'outils interactifs dans Hugo

Comme vous avez pu le constater, le site a un peu évolué, et propose désormais des outils. En considérant que Hugo est un générateur de site « statique », une des questions qui s’est posé est : comment publier des pages qui intéragissent avec l’utilisateur, donc contenant du code, sans pour autant toucher systématiquement au thème ? Je vais décrire dans cet article, mon cheminement, et la solution sélectionnée.

Sur ce blog, je suis l’auteur du contenu, mais également du thème. Je peux donc me permettre une certaine porosité entre ce qui est fait par le contenu, et ce qui est fait par le thème. Néanmoins, un couplage fort entre le contenu et le thème n’est pas souhaitable

  • Ce n’est pas dans l’esprit de l’outil,
  • Cela rendrait les développements plus complexes, et nettement plus difficiles à maintenir.

La question

Mon objectif était de mettre à disposition, quelques outils pour les photographes. Quand je parle d’outils, je parle d’interactions avec les utilisateurs, et quelques calculs.

Je souhaitais quelque chose de simple, notamment sans structure front-end/back-end (client/serveur). Comment implémenter ce genre de chose ?

Les réponses

J’ai testé trois approches :

  • Méthode 1 « classique » : les outils sont basés sur la notion de Layout de Hugo,
  • Méthode 2 « Layout unique » : Tous les outils peuvent utiliser le même Layout,
  • Méthode 3 « Shortcode » : Un shortcode permet l’exécution de script dans un article.

J’ai exclu certaines approches du type « client / serveur » (voir Jamstack ) plus complexes et nécessitant un hébergement. Je réfléchirai peut-être à cette approche ultérieurement.

Méthode 1 « classique »

Principe: Chaque outil a son propre « Layout » (au sens Hugo du terme). Donc pour un outil

  • un document dans le contenu,
  • une template dans le thème.

Nous aurons donc un contenu qui ressemble à cela:

Front-Matter du document:

1
2
3
4
+++
layout = 'exposition-triangle'
title = 'Exposition '
+++

Corps du document

1
2
3
Summaire
<!--more-->
Contenu de l'article

Dans le thème, il faut développer un layout spécifique pour chaque outil:

Layouts/
├── _markup
│   ├── ...
│   └── ...
├── _partials
│   ├── ...
│   └── ...
├── _shortcodes
│   ├── ...
│   └── ...
├── baseof.html        ──────────┐   
├── home.html                    │
├── single.html                  │
├── section.html                 │ Templates pour les pages standards
├── taxonomy.html                │
├── term.html                    │
├── ... ...            ──────────┘  
├── Layout-for-tool-1  ──────────┐ 
├── Layout-for-tool-2            │ Layouts à développer
├── ... ...                      │
├── Layout-for-tool-n  ──────────┘
└── ... ...

Avec un code du type

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{{- define "main" -}}
    <h2>{{- .Title -}}</h2>

    {{- .Summary -}}{{/* Ici on affiche le texte du sommaire */}}

    {{- .ContentWithoutSummary -}}{{/* Ici on affiche le texte de l'article */}}

    <div id="toolcontainer">
        <select id="select-box">
            <option value="1">option 1</option>        
            <option value="2">option 2</option>        
            <option value="3">option 3</option>
        </select>

        <div id="results"></div>
    </div>
    <script>
        const selectBox = document.getElementbyID("select-box");

        /* calcul / traitement en fonction du choix */

        const resultsBox = document.getElementbyID("results");
        resultBox.InnerHTML = /* résultat des calculs */
    </script>
{{- end -}}

Avantages

  • Relativement simple,
  • Les pages peuvent être très différentes en terme de design / structure,

Inconvénients

  • Modification du thème à chaque outil (ou structure d’outil),
  • Potentiellement beaucoup de fichiers avec la même structure, donc beaucoup de code répété, mais surtout beaucoup de code à modifier en cas de changement de structure du site,
  • La partie contenu est obligatoirement placée à un endroit unique dans la page (celle définie dans le layout).

Méthode 2 : Layout unique

Avec cette méthode 2, l’approche est toujours basée sur la notion de Layout, mais nous n’en n’avons qu’un seul, et nous spécifions le script à exécuter dans les paramètres.

Les articles vont prendre la forme suivante :

Front-Matter du document

1
2
3
4
5
+++
title  = ''
layout = 'layout-for-tools'
script = 'my-script-1.js'
+++

Corps du document

1
2
3
Summaire
<!--more-->
Contenu de l'article

Dans le thème, il ne faut développer qu’un seul layout:

Layouts/
├── _markup
│   ├── ...
│   └── ...
├── _partials
│   ├── ...
│   └── ...
├── _shortcodes
│   ├── ...
│   └── ...
├── baseof.html        ──────────┐   
├── home.html                    │
├── single.html                  │
├── section.html                 │ Templates pour les pages standards
├── taxonomy.html                │
├── term.html                    │
├── ... ...            ──────────┘  
├── Layout-for-tool  <------------ Layout unique à développer
└── ... ...

Avec un code pour le layout

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{{- define "main" -}}
    <h2>{{- .Title -}}</h2>

    {{- .Summary -}}{{/* Ici on affiche le texte du sommaire */}}

    {{- .ContentWithoutSummary -}}{{/* Ici on affiche le texte de l'article */}}

    <div id="toolcontainer">
        {{- if .Params.script -}}
            <script src="{{- "js/" | relURL -}}{{- .Params.script -}}"></script>
        {{- end -}}
    </div>
{{- end -}}

Avantages:

  • Nous n’avons qu’un seul layout, donc pas de duplication de code, et une modification unique en cas de changement de structure,
  • Pas plus compliqué que le layout précédent,

Inconvénients:

  • Pas plus de souplesse sur le positionnement du contenu que pour la méthode 1.
  • Il faut que l’outil soit intégralement généré / géré par le script (y compris les formulaires, par exemple).

Méthode 3 : Utilisation d’un shortcode

Cette approche n’utilise plus la notion de layout. Le contenu est « formaté » via les templates standards (single.html par exemple).

La publication de l’outil passe par un shortcode. Le contenu prend donc la forme suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
Résumé
<!--more-->
Contenu
Contenu 
contenu

{<< tool script="script.js" >>}
    <style>
        Styles CSS nécessaires au script
    </style>
    Code HTML (pour les interactions, sous forme de formulaire, par exemple)
{<< /tool >>}

Contenu
Contenu 
contenu

Dans le thème, il ne faut développer qu’un seul shortcode:

Layouts/
├── _markup/
│   ├── ...
│   └── ...
├── _partials/
│   ├── ...
│   └── ...
├── _shortcodes/
│   ├── shortcode_for_tool  <----- Shortcode unique à développer
│   └── ...
├── baseof.html        ──────────┐   
├── home.html                    │
├── single.html                  │
├── section.html                 │ Templates pour les pages standards
├── taxonomy.html                │
├── term.html                    │
└── ... ...            ──────────┘  

Le shortcode prend la forme suivante :

1
2
3
4
5
6
7
<div class="tool-wrapper">
   {{- .Inner -}}
</div>
{{- $script := cond (.IsNamedParams) (.Get "script") (.Get 0)   -}}
{{- if $script -}}
     <script type="module" src="{{- printf "js/%s" $script | relURL -}}"></script>
{{- end -}}

L’outil se présentera en deux parties

  • Le fichier de contenu peut être n’importe ou dans la structure du répertoire /content (pourquoi pas dans un sous-répertoire « outil »),
  • Le fichier Javascript devra être placé dans le répertoire /static (celui de la racine, pas celui du thème).
mysite/
├── content/
│   └── tools/
│       └── mytool.md
├── static/
│   └── js/
│       └── script_for_my_tool.js
└── ... ...

Avantages :

  • Pas de layout à développer,
  • Le shortcode est unique, et universel,
  • L’outil peut être placé n’importe ou dans le contenu.

Inconvénient(s) :

  • L’inclusion des styles dans le code HTML n’est pas correct / conforme aux pratiques (mais cela fonctionne),

Choix et retour d’expérience

J’ai choisi la méthode 3, parce que c’est la méthode la plus souple, et celle qui offre le plus faible couplage entre le contenu et le thème.

Concrètement

  • L’article contient les formulaires, les structures pour présenter les résultats,
  • Le script récupère les valeurs des formulaires de façon classique (document.getElementById par exemple), et construit le résultat sous forme de table ou de graphique.

Pour la partie graphique justement, j’ai utilisé le format SVG , qui se manipule très facilement en Javascript puisque nous pouvons ajouter / supprimer / modifier chaque composant, comme nous pouvons le faire avec un document HTML (DOM).

J’en suis à trois outils développés.

Premier constat : Il peut être un peu pénible de faire le développement directement dans Hugo  pour plusieurs raisons.

  • Les potentiels temps d’attente pour la regénération des pages à chaque modification du script,
  • Le debugging implique de naviguer dans l’ensemble du code de la page, et pas uniquement sur la partie qui nous intéresse,

Je me suis donc construit une page modèle, autonome, que je peux afficher sans dépendre de l’environnement d’Hugo. Une fois que l’outil est finalisé, il ne reste qu’à faire des copier/coller des différentes parties (styles, html, et javascript), dans l’article que vous souhaitez publier.

Second constat : La partie « styles » est un peu pénible à débugger une fois dans le document. Si votre thème utilise un framework, je recommande d’utiliser les styles de ce framework, ce qui vous évite le développement de styles spécifiques. Ce site utilise une version personnalisée  de Boostrap. J’utilise autant que possible les styles de ce framework, ce qui me permet de réduire au maximum la création de styles spécifiques.

Javascript et modules

Les outils publiés jusqu’à présent concernent la résolution et l’impression. Ils utilisent les mêmes informations de base, et utilisent les mêmes fonctions. Pour éviter la duplication du code, j’ai regroupé les fonctions communes grâce à la notion de modules . Globalement,

  • les fonctions communes peuvent être regroupées dans un fichier spécifique,
  • Les scripts implémentant les outils font appel à ces fonctions comme si elles étaient déclarées localement.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Exemple de données accessibles depuis d'autres scripts
export const apertures = [
    { label: "f/1.4", value: 1.4 },
    { label: "f/2",   value: 2   },
    { label: "f/2.8", value: 2.8 },
    { label: "f/4",   value: 4   },
    { label: "f/5.6", value: 5.6 },
    { label: "f/8",   value: 8   },
    { label: "f/11",  value: 11  },
    { label: "f/16",  value: 16  },
    { label: "f/32",  value: 32  }
];

// Exemple de fonctions accessibles depuis les autres scripts
export function buildApertureTable(object) {
    apertures.forEach(item => {
        let tr = document.createElement("tr");
        let td = document.createElement("td");
        td.innerHTML = item.value;
        tr.appendChild(td);
        object.appendChild(tr);
    });
}

L’outil prendra la forme suivante :

1
2
3
4
    import { apertures, buildApertureTable } from "./common.js";

    const tbody = document.querySelector("#aperture-table tbody");
    buildApertureTable(tbody);

Utilisation de plusieurs fonctions dans un même article

Comment gérer, au sein d’un même article, plusieurs appels via le shortcode, de fonctions se trouvant dans un même fichier Javascript. L’exemple suivant montre deux appels au même script (script.js), mais avec deux fonctions différentes (fonction1 et fonction2).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Résumé
<!--more-->
Contenu
Contenu 
contenu
{<< tool script="script.js" funcname="function1" >>}
    <style>
        Styles CSS nécessaires à la fonction 1
    </style>
    Code HTML pour la fonction 1
{<< /tool >>}
Contenu
Contenu 
contenu
{<< tool script="script.js" funcname="function2" >>}
    <style>
        Styles CSS nécessaires à la fonction 2
    </style>
    Code HTML pour la fonction 2
{<< /tool >>}
Contenu
Contenu 
contenu

Dans un premier temps, j’ai modifié le shortcode de la façon suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<div class="tool-wrapper">
   {{- .Inner -}}
</div>
{{- $script   := cond (.IsNamedParams) (.Get "script") (.Get 0)   -}}
{{- $funcname := cond (.IsNamedParams) (.Get "funcname") (.Get 1) -}}
{{- if $script -}}
    <script type="module" src="{{- printf "js/%s" $script | relURL -}}"></script>
    <!-- if function is empty, we do nothing, else we start it -->
    {{- if $funcname -}}
        <script>
            document.addEventListener("DOMContentLoaded", function() {
                if (typeof window[{{- $funcname -}}] === "function") {
                    window[{{- $funcname -}}]();
                }
            });
        </script>
    {{- end -}}
{{- end -}}

J’ai rencontré deux problèmes :

  • L’utilisation des modules fait que les fonctions ne sont pas présentes dans le domaine de nom global (window). Donc la ligne window[{{- $funcname -}}](); ne fonctionne pas,
  • Le script est chargé deux fois (une fois par appel au shortcode).

J’ai réglé le premier problème, en utilisant un « registre » jouant le rôle de domaine de noms (namespace). Dans le fichier javascript, nous avons la syntaxe suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Initialize the registry
window.egetools = window.egetools || {}
... ...
// Method 1 : Define the function
function myFunctionExecutor() {
	... ...
}
// Method 1 : Record the function
window.egetools.push( { myFunction: myFunctionExecutor });

// Method 2 : Define and record the function at the same time
window.egetools.myFunction2 = function() {
	... ...
}

Pour ne charger le script qu’une seule fois, je mémorise le chargement grâce aux fonctions de .Page.Store.

Le code du shortcode évolue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="tool-wrapper">
   {{- .Inner -}}
</div>
{{- $script   := cond (.IsNamedParams) (.Get "script") (.Get 0)   -}}
{{- $funcname := cond (.IsNamedParams) (.Get "funcname") (.Get 1) -}}
{{- if $script -}}
    <!-- is the script already loaded ? -->
    {{- if not (.Page.Store.Get $script) -}}
	<!-- No, we load it, and record the load -->
        <script type="module" src="{{- printf "js/%s" $script | relURL -}}"></script>
        {{- .Page.Store.Set $script true -}}
    {{- end -}}
    <!-- if function is empty, we do nothing, else we start it -->
    {{- if $funcname -}}
        <script>
            document.addEventListener("DOMContentLoaded", function() {
                if (typeof window.egetools[{{- $funcname -}}] === "function") {
                    window.egetools[{{- $funcname -}}]();
                }
            });
        </script>
    {{- end -}}
{{- end -}}

Passage de paramètres

Dernier point à traiter : comment peut-on passer des paramètres aux fonctions que nous appelons ? J’ai d’abord pensé à des balises input cachées contenant les paramètres. C’est relativement simple, et facile à faire, mais pas forcement très élégant, et cela peut être un peu pénible si les paramètres sont nombreux.

L’autre solution testée et finalement choisie est de

  • Saisir les paramètres lors de l’appel au shortcode,
  • Transmettre ces paramètres via une variable au format JSON.

Explications

L’appel au shortcode se présente de la façon suivante:

1
2
3
4
5
6
{<< tool script="script.js" funcname="function2" parametre1="valeur1" parametre2=valeur2 >>}
    <style>
        Styles CSS nécessaires au script
    </style>
    Code HTML
{<< /tool >>}

Le shortcode évolue de nouveau pour prendre en compte les paramètres

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<div class="tool-wrapper">
   {{- .Inner -}}
</div>
<!-- Initiate the variables --> 
{{- $params := dict -}}
{{- $script := ""   -}}
{{- $func   := ""   -}}
<!-- Get all parameters -->
{{- range $key,$value := .Params -}}
	<!-- if the parameter is script of funcname, we initiate the corresponding variable -->
    {{- if eq $key "script" -}}
	    {{- $script = $value -}}
    {{- else if eq $key "funcname" -}}
	    {{- $func = $value -}}
    {{- else }}
		<!-- if the parameter is NOT script of funcname, it is a parameter, we add it to the parameter list -->
	    {{- $params = merge $params (dict $key $value) -}}
    {{- end -}}
{{- end -}}
{{- if $script -}}
	<!-- is the script already loaded ? -->
    {{- if not (.Page.Store.Get $script) -}}
		<!-- No, we load it, and record the load -->
        <script type="module" src="{{- printf "js/%s" $script | relURL -}}"></script>
        {{- .Page.Store.Set $script true -}}
    {{- end -}}
    {{- if $func -}}
	    {{- $params = $params | jsonify | safeJS -}}
		<!-- Params will contain a JSON string : 
			{ 
				parametre1: "valeur1",
				parametre2: valeur2
			}
	  	-->
        <script>
            document.addEventListener("DOMContentLoaded", function() {
                if (typeof window.egetools[{{- $func -}}] === "function") {
                    window.egetools[{{- $func -}}]({{- $params -}});
                }
            });
        </script>
    {{- end -}}	
{{- end -}}

Les fonctions Javascript fonctionnant dans ce mode doivent prendre la forme suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<script>
	function mafonction(params = {}) {

  		const defaults = { parametre1: defaultvalue1, parametre2: defaultvalue2 };
  		params = Object.assign({}, defaults, params);

		/* access to the parametre1 => params.parametre1 */
		/* access to the parametre2 => params.parametre2 */
		... ...
	}
</script>

Si cette fonction doit être appelée par une autre fonction Javascript, il faudra l’appeler de la façon suivante :

1
2
3
4
5
6
7
8
9
<script>
	function callingFunction() {
		myFunction({ parametre1: "valeur1", parametre2: valeur2 });
	}

	function myFunction(params = {}) {
		... ...
	}
</script>

Conclusion

La solution sélectionnée est rapide et efficace. Elle est probablement moins élégante que d’autres, elle ne protège pas contre le vol du code, mais elle très souple dans son utilisation. Je n’ai pas rencontré de points bloquants pour l’instant. Si votre objectif est d’avoir quelques outils parmi de très nombreux articles, sans vous prendre la tête, cette solution est parfaite.

Pour les puristes, la méthode génère un code qui n’apparaîtra pas comme 100% conforme, avec notamment des styles, et des scripts placés au milieu des pages HTML générées. Cet inconvénients pourraient être résolu en exploitant un peu plus la notion de registre du chapitre précédent, mais cela apporterait un peu de complexité dans laquelle je ne souhaite pas m’investir pour l’instant.

Une version « client / serveur » permettrait d’occulter le code, mais la solution serait plus complexe, et poserait beaucoup plus de questions sur la sécurité et l’hébergement.