Générer ses favicons au build avec real-favicon : des fichiers déterministes sans dépendre d’un framework

TL;DR
Les favicons paraissent secondaires jusqu’au moment où ils commencent à diverger selon les environnements. Sur un projet, on trouve un simple favicon.ico, sur un autre un manifeste incomplet, et sur un troisième un plugin de build qui modifie le HTML à l’exécution. Je voulais quelque chose de plus simple : générer les fichiers une fois, les écrire sur disque, puis laisser l’application les inclure explicitement. C’est précisément pour cela que real-favicon existe.

Génération de favicons au build avec real-favicon Illustration d’un pipeline de build qui génère les favicons et le fragment HTML associé avant le déploiement, dans un flux déterministe sans dépendance à un framework particulier.

Les favicons sont souvent traités comme un détail, alors qu’ils couvrent bien plus de surface qu’on ne le pense : onglets du navigateur, raccourcis épinglés, écrans d’accueil mobiles, manifestes d’application web et couleurs de thème.

La génération d’images elle-même est déjà prise en charge par les bibliothèques sur lesquelles real-favicon s’appuie : @realfavicongenerator/generate-favicon et @realfavicongenerator/image-adapter-node. Et si vous voulez voir le projet d’origine dans son ensemble, le site officiel est ici : RealFaviconGenerator.

Ce qui me manquait, en pratique, c’était surtout la façon d’orchestrer tout ça proprement :

  • à quel moment lancer la génération
  • où écrire les fichiers
  • comment consommer le HTML généré
  • comment garder le processus déterministe en CI comme en local

C’est ce qui m’a poussé à créer @jturbide/real-favicon, avec le code source disponible sur GitHub : un wrapper léger au-dessus de la chaîne d’outils officielle, avec un rôle volontairement simple. À partir d’une image source et d’une configuration, il produit des fichiers favicon statiques ainsi qu’un fragment HTML statique.

Le vrai problème, ce n’est pas de générer les favicons

Si tout ce qu’il vous faut est un unique favicon.ico, vous pouvez vous arrêter là. Le vrai sujet apparaît quand vous voulez l’ensemble moderne des fichiers favicon, avec le même niveau de rigueur que pour n’importe quel autre artefact de build.

La génération manuelle est pénible pour des raisons évidentes. Le problème plus subtil, c’est que certaines solutions mélangent trop facilement build, framework et exécution :

  • elles cachent le moment où la génération se produit
  • elles injectent automatiquement le markup
  • elles couplent la génération d’assets à un lifecycle de framework
  • elles rendent la CI et le débogage moins clairs qu’ils ne devraient l’être

Pour les favicons, je pense que c’est le mauvais compromis.

La génération de favicons devrait se faire avant le build applicatif, écrire des fichiers explicites, puis s’arrêter là. L’application, elle, ne devrait faire qu’une seule chose : inclure le résultat.

Ce que real-favicon apporte vraiment

real-favicon ne réimplémente pas le traitement d’image. Il s’appuie sur @realfavicongenerator/generate-favicon et @realfavicongenerator/image-adapter-node, puis se concentre sur l’orchestration :

  • génération d’assets statiques au build
  • écriture déterministe des fichiers
  • génération déterministe du HTML <link> et <meta>
  • cœur indépendant du framework (agnostique)
  • adaptateurs légers pour Node et Vite
  • hooks de cycle de vie pour le logging et l’observabilité

Cette portée réduite est justement l’intérêt du projet. L’objectif est d’avoir une brique d’infrastructure simple, explicite et prévisible.

Le fonctionnement que je voulais dans un vrai projet

Sur ce site, la génération des favicons fait partie du pré-build, au même titre que les métadonnées, le sitemap et la génération d’images.

Le package lui-même est disponible sur npmx.dev pour @jturbide/real-favicon, et son code source est sur GitHub.

L’installation du package est volontairement banale :

yarn add -D @jturbide/real-favicon

Ensuite, on l’intègre dans un script :

{
  "scripts": {
    "generate:favicon": "tsx scripts/generate-favicon.ts",
    "prebuild": "tsx scripts/prebuild.ts",
    "build": "vite build"
  }
}

Puis scripts/prebuild.ts l’exécute comme une étape du pipeline d’assets :

import {execSync} from 'node:child_process'

for (const cmd of [
  'yarn generate:assets',
  'yarn optimize:assets',
  'yarn generate:metadata',
  'yarn generate:favicon',
  'yarn generate:og-image',
  'yarn generate:sitemap',
  'yarn generate:llms',
  'yarn generate:rss',
]) {
  execSync(cmd, {stdio: 'inherit'})
}

Concrètement, cela veut dire que l’étape favicon s’exécute avant que SvelteKit ou Vite ne construise réellement l’application. Au moment où l’application est rendue, les fichiers favicon générés et le fragment HTML existent déjà.

Un script de génération concret

C’est exactement le type d’interface que je veux utiliser : une image source, un objet de configuration, un répertoire de sortie explicite.

import {generateFaviconsNode} from '@jturbide/real-favicon'
import {IconTransformationType} from '@realfavicongenerator/generate-favicon'

await generateFaviconsNode({
  skipIfExists: false,
  copyIcoToRoot: true,
  source: './src/lib/assets/images/jturbide-logo-140x160.png',
  htmlOutput: './src/lib/generated/favicons.html',
  outputDir: './static/favicons',
  rootDir: './static',
  path: '/favicons/',
  icon: {
    desktop: {
      regularIconTransformation: {
        type: IconTransformationType.Background,
        backgroundColor: '#ffffff',
        backgroundRadius: 0.4,
        imageScale: 1,
        brightness: 0,
      },
      darkIconTransformation: {
        type: IconTransformationType.Background,
        backgroundColor: '#ffffff',
        backgroundRadius: 0.4,
        imageScale: 1,
        brightness: 0,
      },
      darkIconType: 'none',
    },
    touch: {
      transformation: {
        type: IconTransformationType.Background,
        backgroundColor: '#ffffff',
        backgroundRadius: 0,
        imageScale: 1,
        brightness: 0,
      },
      appTitle: 'jTurbide',
    },
    webAppManifest: {
      transformation: {
        type: IconTransformationType.Background,
        backgroundColor: '#ffffff',
        backgroundRadius: 0,
        imageScale: 0.7,
        brightness: 0,
      },
      backgroundColor: '#ffffff',
      themeColor: '#39464a',
      name: 'Julien Turbide',
      shortName: 'jTurbide',
    },
  },
})

Quelques choix de conception sont importants ici :

  • source pointe vers un asset standard du projet
  • outputDir est l’endroit où les fichiers générés sont écrits
  • htmlOutput est un fragment statique, pas quelque chose d’injecté implicitement
  • path contrôle les URLs publiques indépendamment du système de fichiers
  • hooks peuvent être ajoutés pour le logging sans coupler la librairie à un logger particulier

Le résultat est déterministe et inspectable. Si quelque chose casse, il n’y a aucun mystère sur l’endroit où les fichiers devraient se trouver ni sur la manière dont ils ont été produits.

Ce que l’application consomme réellement

L’un des aspects les plus appréciables de cette approche, c’est que l’application n’a pas besoin de connaître la génération des favicons. Elle consomme simplement un fragment HTML statique.

Le HTML généré ressemble à ceci :

<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
<link rel="manifest" href="/favicons/site.webmanifest" />
<meta name="theme-color" content="#39464a" />

C’est exactement ce que je veux : du markup HTML classique, prêt à être inséré là où le framework attend du contenu pour le <head>.

L’intégration SvelteKit reste explicite

Avec SvelteKit, le HTML généré est importé comme fichier brut puis injecté pendant le rendu.

import type {Handle} from '@sveltejs/kit'
import {sequence} from '@sveltejs/kit/hooks'
import {paraglideMiddleware} from '$lib/paraglide/server'
import favicons from '$lib/generated/favicons.html?raw'

const paraglideHandle: Handle = ({event, resolve}) =>
  paraglideMiddleware(event.request, ({request: localizedRequest, locale}) => {
    event.request = localizedRequest
    return resolve(event, {
      transformPageChunk: ({html}) => html.replace('%paraglide.lang%', locale),
    })
  })

const faviconHandle: Handle = ({event, resolve}) => {
  return resolve(event, {
    transformPageChunk: ({html}) => html.replace('%favicons.head%', favicons),
  })
}

export const handle: Handle = sequence(paraglideHandle, faviconHandle)

Ensuite, le template de l’application expose un placeholder dans le <head> :

<!doctype html>
<html lang="%paraglide.lang%">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    %favicons.head%
    %sveltekit.head%
  </head>
</html>

Le tout reste explicite, lisible et simple à déboguer. Rien ne modifie le document de façon implicite.

Si vous préférez un plugin Vite, il existe aussi

Je préfère toujours l’approche par script Node parce qu’elle est plus simple à raisonner, surtout en CI, mais un plugin Vite existe aussi si vous préférez laisser cette orchestration à l’outil de build :

import {defineConfig} from 'vite'
import {realFavicon} from '@jturbide/real-favicon'

export default defineConfig({
  plugins: [
    realFavicon({
      source: './logo.png',
      outputDir: './public/favicons',
      htmlOutput: './src/generated/favicons.html',
      path: '/favicons/',
      icon: {
        // Paramètres RealFaviconGenerator
      },
    }),
  ],
})

Ce plugin suit malgré tout les mêmes règles : générer les fichiers, écrire le HTML, éviter tout comportement caché à l’exécution.

Pourquoi cette approche fonctionne bien

Les bénéfices pratiques ne sont pas spectaculaires, mais c’est exactement le type de fiabilité sans surprise que j’attends d’un outil de build :

  • des entrées identiques produisent des sorties identiques
  • le build peut échouer tôt au lieu de livrer des assets manquants
  • la CI et le local se comportent de la même manière
  • l’application garde son mode d’intégration habituel
  • aucune logique favicon ne s’exécute en production

C’est l’un de ces cas où “moins de magie” donne réellement une meilleure expérience de développement.

Ce que le projet ne cherche volontairement pas à faire

Pour garder le package petit et prévisible, real-favicon évite volontairement plusieurs fonctionnalités tentantes :

  • pas de génération à l’exécution
  • pas d’injection HTML automatique
  • pas de dépendance à un framework particulier
  • pas de comportement spécifique au serveur de dev ou au HMR
  • pas d’inférence de configuration ni de conventions implicites

Si vous voulez un plugin de framework très prescriptif qui prend tout en charge, ce n’est pas le bon outil. Si vous voulez une étape favicon déterministe qui se comporte comme n’importe quel autre générateur d’artefacts de build, il correspond beaucoup mieux.

Conclusion

La valeur principale de ce projet n’est pas le fait qu’il génère des favicons. Beaucoup d’outils savent déjà faire cela. Sa vraie valeur, c’est qu’il intègre cette génération proprement dans un pipeline de build moderne, sans masquer ce qui se passe réellement.

C’est un petit gain, mais un gain concret.

Si vous voulez voir l’implémentation ou l’essayer dans votre propre projet, voici les liens les plus utiles :

25 mars 2026 par Julien Turbide