Récemment j'ai eu à faire du templating de fichier html, mais je ne voulais pas utiliser de dépendance quelconque, car j'étais dans un cas très simple et on a déjà tout ce qu'il faut en JavaScript/TypeScript pour ne pas s'encombrer d'outil compliqué pour ça. Je vous montre 3 manières pour faire ça.

Contexte

Imaginons qu'on veuille renvoyer des pages html depuis un backend en JavaScript. On veut une page avec un formulaire de login, une page avec un listing de livre et une page avec le détail d'un livre.

On veut avoir classiquement avoir une barre de navigation en haut de la page avec le nom du site et les liens de base. On veut aussi un footer avec les copyrights. On ne veut pas avoir à redéfinir pour chaque page les éléments et imports communs.

Le html simplifié des 3 pages (il manque sans doute des meta, d'autres styles, plus de html pour avoir un contenu qui s'affiche bien, etc.) :

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Login - Mes Livres</title>
        <link rel="stylesheet" href="https://cdn.example.com/csslib.min.css">
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <link rel="stylesheet" href="/public/style.css">
    </head>
    <body>
        <div class="navbar">
            <div class="navbar-title">Mes Livres</div>
        </div>
        <form method="POST" action="/signin">
            <div class="field">
                <label for="login">Login</label>
                <input type="text" id="login" name="login" />
            </div>
            <div class="field">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" />
            </div>
            <button type="submit">Login</button>
        </form>
        <div class="footer">
            <div class="copyrights">Creative Common CC-BY</div>
        </div>
    </body>
</html>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Mes Livres</title>
        <link rel="stylesheet" href="https://cdn.example.com/csslib.min.css">
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <link rel="stylesheet" href="/public/style.css">
    </head>
    <body>
        <div class="navbar">
            <div class="navbar-title">Mes Livres</div>
        </div>
        <div class="card-container">
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : D’un monde à l’autre</strong> de <em>Pierre Bottero</em>
                </div>
            </div>
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : Les Frontières de glace</strong> de <em>Pierre Bottero</em>
                </div>
            </div>
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : L'Île du destin</strong> de <em>Pierre Bottero</em>
                </div>
            </div>
        </div>
        <div class="footer">
            <div class="copyrights">Creative Common CC-BY</div>
        </div>
    </body>
</html>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>La Quête d'Ewilan : D’un monde à l’autre - Mes Livres</title>
        <link rel="stylesheet" href="https://cdn.example.com/csslib.min.css">
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <link rel="stylesheet" href="/public/style.css">
    </head>
    <body>
        <div class="navbar">
            <div class="navbar-title">Mes Livres</div>
        </div>
        <div class="card-container">
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : D’un monde à l’autre</strong> de <em>Pierre Bottero</em>
                </div>
                <div class="card-content">
                    <section>
                        Descriptions:
                        <p>Camille a 13 ans lorsqu'elle découvre Gwendalavir. Là, elle découvre aussi ses immenses pouvoirs. Et son véritable nom : Ewilan. Sa quête ne fait que commencer. </p>
                    </section>
                    <section>
                        Autres tomes de la série :
                        <ul>
                            <li>La Quête d'Ewilan : Les Frontières de glace</li>
                            <li>La Quête d'Ewilan : L'Île du destin</li>
                        </ul>
                    </section>
                </div>
            </div>
        </div>
        <div class="footer">
            <div class="copyrights">Creative Common CC-BY</div>
        </div>
    </body>
</html>

Approche fonction

Une première idée pourrait être de faire ça avec des fonctions. Disons une fonction par page, avec utilisation d'une fonction commune pour les morceaux de html partagés.

const html = String.raw;

function basePage(title: string, content: string): string {
    return html`
    <!DOCTYPE html>
        <html>
            <head>
                <meta charset="UTF-8">
                <title>${title}</title>
                <link rel="stylesheet" href="https://cdn.example.com/csslib.min.css">
                <meta name="viewport" content="width=device-width, initial-scale=1.0" />
                <link rel="stylesheet" href="/public/style.css">
            </head>
            <body>
                <div class="navbar">
                    <div class="navbar-title">Mes Livres</div>
                </div>
                ${content}
                <div class="footer">
                    <div class="copyrights">Creative Common CC-BY</div>
                </div>
            </body>
        </html>
    `;
}

function loginPage(): string {
    return basePage(
        'Login - Mes Livres',
        html`
        <form method="POST" action="/signin">
            <div class="field">
                <label for="login">Login</label>
                <input type="text" id="login" name="login" />
            </div>
            <div class="field">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" />
            </div>
            <button type="submit">Login</button>
        </form>
        `;
    );
}

function bookListPage(): string {
    return basePage(
        'Mes Livres',
        html`
        <div class="card-container">
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : D’un monde à l’autre</strong> de <em>Pierre Bottero</em>
                </div>
            </div>
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : Les Frontières de glace</strong> de <em>Pierre Bottero</em>
                </div>
            </div>
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : L'Île du destin</strong> de <em>Pierre Bottero</em>
                </div>
            </div>
        </div>
        `;
    );
}

function bookDetailsPage(): string {
    return basePage(
        "La Quête d'Ewilan : D’un monde à l’autre - Mes Livres",
        html`
        <div class="card-container">
            <div class="card">
                <div class="card-title">
                    <strong>La Quête d'Ewilan : D’un monde à l’autre</strong> de <em>Pierre Bottero</em>
                </div>
                <div class="card-content">
                    <section>
                        Descriptions:
                        <p>Camille a 13 ans lorsqu'elle découvre Gwendalavir. Là, elle découvre aussi ses immenses pouvoirs. Et son véritable nom : Ewilan. Sa quête ne fait que commencer. </p>
                    </section>
                    <section>
                        Autres tomes de la série :
                        <ul>
                            <li>La Quête d'Ewilan : Les Frontières de glace</li>
                            <li>La Quête d'Ewilan : L'Île du destin</li>
                        </ul>
                    </section>
                </div>
            </div>
        </div>
        `;
    );
}

const loginPageHtml = loginPage();
const bookListPageHtml = bookListPage();
const bookDetailsPageHtml = bookDetailsPage();

Plusieurs choses à dire. Déjà vous noterez l'utilisation de la template string function html, qui n'est qu'un alias de String.raw. C'est une petite technique que j'ai trouvé pour avoir de la coloration syntaxique sur du html sous forme de chaîne de caractère avec VSCode (mais ça doit fonctionner avec d'autres éditeurs/IDE) en installant un plugin pour Lit. Vous verrez qu'avec cette technique vous aurez une coloration et une aide pour développer similaire à du JSX ou des fichiers html.

Ensuite là on voit qu'on a beaucoup moins de répétition de code, mais l'appel à basePage n'offre pas quelque chose de très lisible. En effet, là on a deux paramètres, donc c'est facile de s'y retrouver, mais imaginons qu'on en ait 5-6, quel paramètre correspond à quoi ? Si on veut des paramètres optionnels ? Si on veut des valeurs par défaut ?

Ensuite ça fonctionne, mais si comme moi vous avez fait pas mal de front, vous devez trouver que ça manque de composant.

Sinon j'ai bien un système de templating 100% JavaScript ici, aucun artifice ni bibliothèque externe n'est requis pour que ça fonctionne (si on exclut le compilateur TypeScript). Ça reste une manière très simple de faire, qui n'est peut-être pas la plus adaptée à du html mais qui pourrait s'adapter très bien à d'autres templates.

Plus explicite et plus orientée composant

const html = String.raw;

type HeadProps = { title: string };
function Head({ title }: HeadProps): string {
    return html`
    <head>
        <meta charset="UTF-8">
        <title>${title}</title>
        <link rel="stylesheet" href="https://cdn.example.com/csslib.min.css">
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <link rel="stylesheet" href="/public/style.css">
    </head>
    `;
}

function Header(): string {
    return html`
    <div class="navbar">
        <div class="navbar-title">Mes Livres</div>
    </div>
    `;
}

function Footer(): string {
    return html`
    <div class="footer">
        <div class="copyrights">Creative Common CC-BY</div>
    </div>
    `;
}

type BasePageProps = { title?: string, content: string };
function BasePage({ title = 'Mes Livres', content }: BasePageProps): string {
    return html`
    <!DOCTYPE html>
    <html>
        ${Head(title)}
        <body>
            ${Header()}
            ${content}
            ${Footer()}
        </body>
    </html>
    `;
}

type FieldProps = { id: string, label: string, type?: string };
function Field({ id, label, type = 'text' }: FieldProps): string {
    return html`
    <div class="field">
        <label for="${id}">${label}</label>
        <input type="${type}" id="${id}" name="${id}" />
    </div>
    `;
}

function LoginPage(): string {
    return BasePage({
        title: 'Login - Mes Livres',
        content: html`
            <form method="POST" action="/signin">
                ${Field({ id: 'login', label: 'Login' })}
                ${Field({ id: 'password', type: 'password' label: 'Password' })}
                <button type="submit">Login</button>
            </form>
            `,
    });
}

type BookListCardProps = { title: string, author: string };
function BookListCard({ title, author }: BookListCardProps): string {
    return html`
    <div class="card">
        <div class="card-title">
            <strong>${title}</strong> de <em>${author}</em>
        </div>
    </div>
    `;
}

function BookListPage(): string {
    return BasePage({
        content: html`
            <div class="card-container">
                ${BookListCard({ title: "La Quête d'Ewilan : D’un monde à l’autre", author: 'Pierre Bottero' })}
                ${BookListCard({ title: "La Quête d'Ewilan : Les Frontières de glace", author: 'Pierre Bottero' })}
                ${BookListCard({ title: "La Quête d'Ewilan : L'Île du destin", author: 'Pierre Bottero' })}
            </div>
            `,
    });
}

const loginPageHtml = LoginPage();
const bookListPageHtml = BookListPage();

J'ai volontairement fait un genre de mix entre React et Lit au niveau de syntaxe et des conventions. Je trouve que ce mixte donne quelque chose d'assez simple à lire comprendre, tout en étant 100% vanilla. Je n'ai gardé que les deux premières pages ici pour gagner en concision.

On voit que simplement en changeant un peu le passage de paramètre et en créant des pseudos composants c'est tout de suite assez naturel de fonctionner comme ça. On peut facilement faire des passages de paramètre, c'est assez explicite comme tous les paramètres sont nommés. On y gagne en permissivité vu qu'on peut maintenant rendre optionnel un paramètre et lui passer une valeur par défaut.

Pour moi il reste un problème ici c'est qu'on appelle explicitement BasePage pour chaque page. Parce que si on fait évoluer BasePage, il va falloir reppasser sur toutes les pages pour changer les paramètres ou autre. Or, on a voulait une approche template et composant, nous simplifier la vie, ce qui est un peu dommage. En même temps on a pas mal de fonction qui ne servirons que pour une seule page, même en mettant tout dans un seul fichier on doit pouvoir faire mieux en termes d'organisation. On pourrait aussi se demander ce qu'il se passe si on veut une page sans header ou sans footer ? En l'état c'est difficile à gérer.

Passage à des classes

const html = String.raw;

abstract class BasePage {

    get title(): string {
        return 'Mes Livres';
    }

    get main(): string;

    render() {
        return html`
        <!DOCTYPE html>
        <html>
            ${this.Head()}
            <body>
                ${this.Header()}
                ${this.main()}
                ${this.Footer()}
            </body>
        </html>
        `;
    }

    // COMPONANTS

    Head(): string {
        return html`
        <head>
            <meta charset="UTF-8">
            <title>${this.title}</title>
            <link rel="stylesheet" href="https://cdn.example.com/csslib.min.css">
            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
            <link rel="stylesheet" href="/public/style.css">
        </head>
        `;
    }

    Header(): string {
        return html`
        <div class="navbar">
            <div class="navbar-title">Mes Livres</div>
        </div>
        `;
    }

    Footer(): string {
        return html`
        <div class="footer">
            <div class="copyrights">Creative Common CC-BY</div>
        </div>
        `;
    }
}


type FieldProps = { id: string, label: string, type?: string };


class LoginPage extends BasePage {
    title = 'Login - Mes Livres';

    main() {
        return html`
        <form method="POST" action="/signin">
            ${this.Field({ id: 'login', label: 'Login' })}
            ${this.Field({ id: 'password', type: 'password' label: 'Password' })}
            <button type="submit">Login</button>
        </form>
        `;
    }

    // COMPONANTS

    private Field({ id, label, type = 'text' }: FieldProps): string {
        return html`
        <div class="field">
            <label for="${id}">${label}</label>
            <input type="${type}" id="${id}" name="${id}" />
        </div>
        `;
    }
}

type BookListCardProps = { title: string, author: string };

class BookListPage extends BasePage {
    main(): string {
        return html`
        <div class="card-container">
            ${this.BookListCard({ title: "La Quête d'Ewilan : D’un monde à l’autre", author: 'Pierre Bottero' })}
            ${this.BookListCard({ title: "La Quête d'Ewilan : Les Frontières de glace", author: 'Pierre Bottero' })}
            ${this.BookListCard({ title: "La Quête d'Ewilan : L'Île du destin", author: 'Pierre Bottero' })}
        </div>
        `;
    }

    // COMPONANTS

    private BookListCard({ title, author }: BookListCardProps): string {
        return html`
        <div class="card">
            <div class="card-title">
                <strong>${title}</strong> de <em>${author}</em>
            </div>
        </div>
        `;
    }
}

const loginPageHtml = new LoginPage().render();
const bookListPageHtml = new BookListPage().render();

Avec des classes on obtient quelque chose de plus structuré et plus déclaratif. BasePage est étendue par chacune de nos pages, ne demande pas trop à comprendre ce que fait BasePage pour que les templates enfants sachent comment fonctionner.

On garde des composants mais sous forme de méthodes sur cette classe, ce qui est structurant. On aurait très bien pu garder des fonctions pour les composants, ça n'aurait pas posé le moindre problème, c'est un choix arbitraire ici.

On notera quand même que comme le Header, Footer et Head sont des méthodes de BasePage, ils peuvent être modifiés pour une page donnée. Par exemple, on pourrait retirer le footer d'une page si on surcharge Footer en lui faisant renvoyer une chaîne vide.

Conclusion

Sans dire que les bibliothèques de templating sont inutiles, je pense que y'a pas mal de cas où elles sont overkills. On a parfois besoin de rien de particulier en plus de JavaScript/TypeScript pour faire ça, donc autant rester là-dessus plutôt qu'avoir des choses compliqués pour faire des choses très simples.

En parallèle, surtout sur des projets perso, dites-vous qu'utiliser des choses totalement vanilla permet aussi d'apprendre à faire certains trucs qu'on pensait compliqué. Même dans le cadre professionnel, faire quelque chose comme ça 100% vanilla, permet de s'assurer qu'on introduira pas une collection de dépendance avec leur lot de potentiel problème, de mise à jour à faire et d'évolution à répercuter dans notre code.

Crédit photo : https://pixabay.com/photos/paints-brushes-multicolored-art-2636552/