Vue normale

Il y a de nouveaux articles disponibles, cliquez pour rafraîchir la page.
À partir d’avant-hierFlux principal

Microfolio - Le portfolio statique qui regarde WordPress de haut

Par : Korben
12 août 2025 à 19:38

Pourquoi prendre des vacances d’été et glander sur une plage, alors qu’on pourrait pondre projet open source qui résout un vrai problème ?

Adrien Revel a fait exactement ça avec Microfolio, et le plus rigolo c’est qu’il s’est fait assister par Claude Code pour développer son bébé. Une fois installé, vous lui balancez vos fichiers dans des dossiers, vous ajoutez du Markdown pour les descriptions, et paf, vous avez un portfolio statique qui a la classe.

Pas de base de données qui rame, pas de CMS à maintenir, pas de plugins WordPress qui vous lâchent au pire moment. Juste des fichiers, du contenu, et un site qui booste.

Microfolio tourne sur SvelteKit 2, l’un des générateurs de sites statiques les plus utilisés, couplé à Tailwind CSS 4. Pour ceux qui ne suivent pas l’actu dev, SvelteKit est un framework qui compile votre code en vanilla JavaScript ultra-optimisé. Du coup, les sites chargent à la vitesse de l’éclair même sur une connexion 3G pourrie.

Maintenant, l’installation, c’est du velours. Par exemple, sous macOS, une ligne de Homebrew et c’est parti :

brew install aker-dev/tap/microfolio
microfolio new mon-portfolio
cd mon-portfolio
microfolio dev

Pour les autres OS, un bon vieux clone Git et pnpm font l’affaire. Le projet est pensé pour les designers, architectes, photographes, bref tous les créatifs qui veulent montrer leur travail sans se prendre la tête avec du code.

Ce qui est intéressant dans l’histoire, c’est qu’Adrien a développé Microfolio avec Claude Code, ce nouvel assistant IA d’Anthropic qui cartonne chez les développeurs. Claude Code est d’ailleurs particulièrement doué pour bosser avec Svelte 5 et sa nouvelle syntaxe de runes. L’IA l’a visiblement bien aidé sur la doc et le code, ce qui montre qu’on peut faire de l’open source de qualité en duo avec une IA.

La démo en ligne montre déjà ce que ça donne à savoir une interface épurée, une navigation fluide entre les projets, une vue carte pour les projets géolocalisés (pratique pour les architectes), et un système de tags pour filtrer le contenu. Le tout responsive évidemment, c’est la base aujourd’hui.

Moi j’en ai fait un site de chat pour rigoler…

Pour le déploiement, GitHub Pages est supporté d’office avec possibilité de mettre un domaine custom. Adrien cherche également des beta-testeurs pour peaufiner le projet avant de sortir la v1.0 à la rentrée. Donc si vous avez un portfolio à refaire ou si vous voulez juste tester un outil moderne, c’est le moment.

Bref, du bon “file-based CMS” comme on les aime. Comme ça, au lieu de stocker vos contenus dans une base de données qui peut foirer, tout est dans des fichiers que vous pouvez versionner avec Git. Vous gardez le contrôle total sur vos données, vous pouvez tout éditer offline, et surtout vous n’êtes pas prisonnier d’un système.

Puis ce combo SvelteKit + Tailwind CSS est vraiment pertinent, je trouve. SvelteKit permet de générer des sites statiques en automatique, ce qui veut dire que votre site est servi en HTML statique pour le SEO, puis devient interactif côté client. C’est le meilleur des deux mondes.

Pour les devs qui veulent contribuer, le code est sur GitHub sous licence MIT. Le projet utilise les dernières versions de tout (SvelteKit 2, Tailwind CSS 4, Node.js 20+), donc c’est aussi l’occasion de jouer avec les dernières technos du moment.

A découvrir ici !

Merci à Adrien Revel pour avoir partagé son projet !

Comment j'ai divisé par 10 le temps de génération de mon site Hugo

Par : Korben
11 août 2025 à 13:46

Vous ne le savez peut-être pas, mais le site sur lequel vous êtes actuellement est un site 100% statique. Je gère le contenu côté back avec un CMS en PHP (rédaction, édition, workflow), mais la partie publique n’exécute aucun PHP : pas de base de données, juste des fichiers HTML/CSS/JS et des images. Et tout ça est généré à partir de fichiers Markdown avec Hugo.

Et optimiser tout ça c’est pas de la tarte car si vos templates sont mal pensés, Hugo peut mettre une plombe à générer le site. Entre partiels recalculés pour rien, boucles trop larges et images retraitées à chaque passage, on flingue les perfs sans s’en rendre compte.

Et c’était mon cas au début. Je regardais avec envie les mecs qui sortaient un build en moins d’une seconde tout en restant réaliste car j’ai quand même environ 60 000 pages à générer. Au final, je suis passé de plusieurs heures de build à environ 5 minutes en optimisant les templates, le cache et le pipeline d’assets.

Car oui, Hugo sait générer la plupart des sites en quelques secondes si vos templates sont propres. Depuis la “million pages release”, les builds sont streamés et la mémoire mieux gérée. Alors si votre génération est lente, c’est qu’il y a (souvent) un souci côté templates.

Tout ceci est encore en cours de tests, et ce n’est pas parfait donc il est possible que ça change à l’avenir et vous faites peut-être autrement (dans ce cas ça m’intéresse !!). Bref, voici mon retour d’XP sous forme de conseils pour transformer, vous aussi, votre escargot en fusée 🚀.

Partials - Cachez-moi tout ça (bien)

Hugo sait mettre en cache le rendu d’un partial. Utilisez partialCached (alias de partials.IncludeCached) partout où la sortie est identique sur beaucoup de pages.

{{/* baseof.html - Template de base optimisé */}}
{{ partialCached "header.html" . }}
{{ partialCached "nav.html" . }}
{{ partialCached "footer.html" . }}

Le truc malin, c’est d’utiliser des variantes (clés) pour changer le cache selon le contexte. Attention quand même car ces arguments de variante ne sont pas passés au partial. En réalité, ils servent uniquement à nommer l’entrée de cache. Si vous devez passer plus de données au partial, mettez-les dans le context via un dict.

{{/* Sidebar: cache par section et par numéro de page */}}
{{ $variant := printf "%s|p%d" .Section (cond .Paginator .Paginator.PageNumber 1) }}
{{ partialCached "sidebar.html" . $variant }}

{{/* Pagination: on passe un contexte enrichi, et on varie par numéro de page */}}
{{ $opts := dict "showCounts" true }}
{{ partialCached "pagination-taxonomy.html" (dict "Page" . "Opts" $opts) .Paginator.PageNumber }}

{{/* Bannières: cache par langue */}}
{{ partialCached "article/patreon-support-banner.html" . .Lang }}

Gardez aussi vos variantes stables et petites (ex. .Lang, .Section, .Title, .Paginator.PageNumber), sinon, vous allez exploser le cache pour rien.

Réf. : partials.IncludeCached / partialCached.

Hugo Pipes - Minify, fingerprint, bundle (et JS qui dépote)

Pour le basique, pas besoin de Gulp/Webpack car Hugo a tout ce qu’il faut, et c’est très rapide. Voici mon bundle CSS unique :

{{/* head/css.html - Bundle CSS optimisé */}}
{{- $styles := slice
"css/reset.css"
"css/main.css"
"css/components/home-cards.css"
"css/components/patreon-card.css"
"css/components/article-content-images.css"
"css/components/lazy-loading.css"
"css/youtube-placeholder.css"
-}}
{{- $cssResources := slice -}}
{{- range $styles -}}
{{- with resources.Get . -}}
{{- $cssResources = $cssResources | append . -}}
{{- end -}}
{{- end -}}

{{/* Concat + minify + fingerprint */}}
{{- $cssBundle := resources.Concat "css/bundle.css" $cssResources | minify | fingerprint -}}

{{/* Preload + feuille finale avec SRI */}}
<link rel="preload" as="style" href="{{ $cssBundle.RelPermalink }}" integrity="{{ $cssBundle.Data.Integrity }}" crossorigin="anonymous">
<link rel="stylesheet" href="{{ $cssBundle.RelPermalink }}" integrity="{{ $cssBundle.Data.Integrity }}" crossorigin="anonymous">

Je regroupe tout avec resources.Concat, puis minify et fingerprint (SRI + cache-busting). Le preload déclenche le chargement au plus tôt, et le link classique prend le relais.

Côté JavaScript, j’utilise js.Build (esbuild) et je bascule les sourcemaps selon l’environnement :

{{/* assets/js/app.js est mon point d'entrée */}}
{{ $opts := dict
"minify" (not hugo.IsDevelopment)
"targetPath" "js/app.js"
"sourceMap" (cond hugo.IsDevelopment "inline" "") /* inline en dev, rien en prod */
}}
{{ $js := resources.Get "js/app.js" | js.Build $opts | fingerprint }}
<script src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}" crossorigin="anonymous" defer></script>

Réfs : Minify, Fingerprint/SRI, js.Build (esbuild), resources.Concat.

Images - Cloudflare Image Resizing + lazy loading intelligent

J’ai également une approche hybride concernant la gestion des images. J’utilise Cloudflare Image Resizing qui génère les variantes côté CDN, et un partial Hugo prépare le srcset + LCP propre.

{{/* partials/responsive-image.html */}}
{{/* Paramètres attendus via dict:
- imageURL (string, chemin vers l'image d'origine dans /static)
- imgWidth, imgHeight (int)
- alt (string)
- class (string optionnelle)
- isLCP (bool) */}}

{{ $imageURL := .imageURL }}
{{ $imgWidth := .imgWidth }}
{{ $imgHeight := .imgHeight }}
{{ $alt := .alt | default "" }}
{{ $class := .class | default "" }}
{{ $isLCP := .isLCP | default false }}

{{ $widths := slice 320 640 960 1280 1920 }}
{{ $qualityMap := dict "320" 85 "640" 85 "960" 88 "1280" 90 "1920" 92 }}
{{ $srcset := slice }}

{{ range $w := $widths }}
{{ if or (eq $imgWidth 0) (ge $imgWidth $w) }}
{{ $q := index $qualityMap (printf "%d" $w) }}
{{ $srcset = $srcset | append (printf "/cdn-cgi/image/width=%d,quality=%d,f=avif%s %dw" $w $q $imageURL $w) }}
{{ end }}
{{ end }}

{{ if $isLCP }}
<img
src="/cdn-cgi/image/width=1280,quality=90,f=avif{{ $imageURL }}"
srcset="{{ delimit $srcset ", " }}"
sizes="(max-width: 1280px) 100vw, 1280px"
fetchpriority="high"
width="{{ $imgWidth }}" height="{{ $imgHeight }}"
alt="{{ $alt }}" class="{{ $class }}">
{{ else }}
{{/* LazySizes: placeholder + data-attrs et data-sizes='auto' */}}
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 {{ $imgWidth }} {{ $imgHeight }}'%3E%3C/svg%3E"
data-src="/cdn-cgi/image/width=1280,quality=90,f=avif{{ $imageURL }}"
data-srcset="{{ delimit $srcset ", " }}"
data-sizes="auto"
loading="lazy"
width="{{ $imgWidth }}" height="{{ $imgHeight }}"
alt="{{ $alt }}" class="lazyload {{ $class }}">
{{ end }}

Comme vous pouvez le voir, j’adapte la qualité selon la plateforme (85% pour mobile / 92% pour desktop), le format AVIF par défaut, et pour l’image LCP, je mets fetchpriority="high" et pas de lazy.

Voici un exemple d’appel :

{{ partial "responsive-image.html" (dict
"imageURL" .Params.featured_image
"imgWidth" 1280
"imgHeight" 720
"alt" .Title
"class" "article-cover"
"isLCP" true
) }}

Cache des ressources - Le secret des rebuilds rapides

Configurez aussi les caches pour éviter de retraiter à chaque build. Le plus important c’est le cache permanent pour les images et autres assets traités.

# hugo.toml
[caches]
[caches.getJSON]
dir = ":cacheDir/:project"
maxAge = "1h"
[caches.getCSV]
dir = ":cacheDir/:project"
maxAge = "1h"
[caches.images]
dir = ":resourceDir/_gen"
maxAge = -1 # NE JAMAIS expirer
[caches.assets]
dir = ":resourceDir/_gen"
maxAge = -1

Et en cas de besoin, pour forcer un refresh : hugo server --ignoreCache.

Réf. : Configure file caches.

Minifiez tout ce qui bouge (config complète)

J’ai aussi ajouté dans mon hugo.toml, une config de minification que voici :

# Minification HTML/CSS/JS/JSON/SVG/XML
[minify]
disableCSS = false
disableHTML = false
disableJS = false
disableJSON = false
disableSVG = false
disableXML = false
minifyOutput = true

[minify.tdewolff]
[minify.tdewolff.html]
keepWhitespace = false
[minify.tdewolff.css]
keepCSS2 = true
precision = 0
[minify.tdewolff.js]
keepVarNames = false
version = 2022
precision = 0

Avec hugo --minify, je gagne ~25% sur le HTML final (variable selon le site).

Réf. : Minify (config).

Organisation des templates - site.RegularPages est votre ami

En régle générale, c’est mieux d’éviter de boucler sur des pages inutiles (taxonomies, terms…). Utilisez aussi site.RegularPages plutôt que .Site.Pages.

{{/* sidebar/recent-posts.html - Articles récents optimisés */}}
{{ $recentPosts := first 6 (where site.RegularPages "Type" "posts") }}
{{ range $recentPosts }}
<li>
<a href="{{ .RelPermalink }}">
{{ $featuredImage := partial "get-image-optimized.html" (dict "page" .) }}
{{ if $featuredImage }}
<img src="{{ $featuredImage.RelPermalink }}" loading="lazy" width="80" height="60" alt="">
{{ else }}
<img src="/img/default-article-image.webp" loading="lazy" width="80" height="60" alt="">
{{ end }}
<h4>{{ truncate 40 .Title }}</h4>
</a>
</li>
{{ end }}

Réfs : site.RegularPages.

Et voici le petit helper image que j’utilise pour éviter les nil :

{{/* partials/get-image-optimized.html */}}
{{ $page := .page }}
{{ $imageName := .imageName | default $page.Params.featured_image }}

{{ $img := false }}
{{ if $imageName }}
{{ with $page.Resources.GetMatch $imageName }}
{{ $img = . }}
{{ end }}
{{ end }}

{{ return $img }}

Configuration globale - Chaque détail compte

Voici également quelques réglages utiles dans mon hugo.toml :

# Désactiver ce qu'on n'utilise pas
disableKinds = ["section"]

# Pagination moderne (Hugo ≥ 0.128)
[pagination]
pagerSize = 39 # 39 articles par page

# Traitement d'images
[imaging]
quality = 80
resampleFilter = "Lanczos"

# Optimisations de build
[build]
writeStats = false
noJSConfigInAssets = true
useResourceCacheWhen = "always"

# Mounts: exclure fichiers lourds
[module]
[[module.mounts]]
source = "content"
target = "content"
excludeFiles = ["**/*.zip", "**/*.log", "**/backup/**", "**/archives/**", "**/exports/**"]
[[module.mounts]]
source = "static"
target = "static"
excludeFiles = ["**/*.zip", "**/*.log", "**/backup/**", "**/archives/**"]

Je mets aussi un timeout large (timeout = "600s") et j’ignore certains warnings verbeux (ignoreLogs = ['warning-goldmark-raw-html']). Et pour la pagination, pagerSize a aussi remplacé les vieux réglages (bon à savoir si vous migrez).

Réf. : PagerSize, Pagination.

Les flags CLI utiles

Perso, j’utilise uniquement les flags suivants quand je lance Hugo :

  • Nettoyage : hugo --gc --cleanDestinationDir pour virer l’obsolète et garder public/ clean.
  • Dev rapide : gardez hugo server tel quel. --disableFastRender seulement si un glitch l’exige. --renderToMemory peut accélérer (au prix de la RAM).
  • Profiler de templates : hugo --templateMetrics --templateMetricsHints liste les templates lents et où placer vos partialCached. C’est comme ça que j’ai vu que ma sidebar coûtait une plombe.

Conditionnez ce que vous chargez

Selon la page, j’adapte aussi mon code. L’image LCP avec fetchpriority="high" (pour éviter le lazy), le reste en lazy + placeholder SVG qui respecte les dimensions (zéro layout shift). Et pour le JS, defer partout et pas de scripts inutiles sur les pages qui n’en ont pas besoin.

Le pattern qui tue - partialCached + variantes calculées

Mon combo préféré, selon le contexte c’est celui-ci :

{{/* Home: variante par numéro de page */}}
{{ partialCached "pagination-home.html" . (cond .Paginator .Paginator.PageNumber 1) }}

{{/* Grilles darticles: variante par (page, index de groupe) */}}
{{ partialCached "articles-grid.html"
(dict "articles" $groupArticles "Site" $.Site)
(printf "p%d|g%d" $paginator.PageNumber $groupIndex) }}

{{/* Éléments vraiment statiques: pas de variante */}}
{{ partialCached "footer.html" . }}

Il faut comme ça trouver le bon équilibre. C’est à dire avoir assez de variantes pour éviter les recalculs, mais pas trop, sinon votre cache devient inutile.

Et en bonus - Ma checklist express !

  • Remplacer les partial chauds par partialCached + variantes propres (au minimum : header, footer, nav, sidebar).
  • Pipeline assets : resources.Concatminifyfingerprint (SRI).
  • Images : Cloudflare Image Resizing (ou .Resize Hugo) + lazy intelligent + placeholder SVG.
  • getJSON : config de cache (maxAge) et --ignoreCache en dev.
  • --templateMetrics + --templateMetricsHints pour viser les pires templates.
  • Pagination : passer à [pagination].pagerSize si vous migrez.
  • Utiliser site.RegularPages au lieu de .Site.Pages dans les boucles.
  • Module mounts avec excludeFiles pour éviter que Hugo scanne backups/archives.
  • Prod : --gc + --cleanDestinationDir + --minify.
  • Cache permanent pour images/assets (maxAge = -1).

Voilà. Avec tout ça, je suis passé de plusieurs heures à quelques minutes pour ~60 000 pages. Les clés : partialCached partout où la sortie ne bouge pas, un bundle CSS/JS propre avec fingerprint/SRI, et site.RegularPages pour ne pas trimbaler les taxonomies dans les boucles.

N’oubliez pas de lancer hugo --templateMetrics --templateMetricsHints pour trouver ce qui coûte cher lors de la génération. Vous verrez, dans 90% des cas c’est un partial appelé 10 000 fois non mis en cache, ou une boucle sur .Site.Pages trop large. Réparez ça, et votre build respira de nouveau.

Et si après tout ça votre build met encore plus de 10 s pour builder moins de 1000 pages, c’est qu’il y a un loup dans vos templates. Réduisez la surface des boucles, chassez les recalculs, et mettez partialCached partout où c’est pertinent !

Bon courage !

❌
❌