Stratégie de slug
Comment les URLs sont construites à partir des slugs, où la détection de collision se fait, et ce qui change quand vous les éditez.
Comment les URLs sont construites à partir des slugs, où la détection de collision se fait, et ce qui change quand vous les éditez.
L'implémentation canonique vit dans src/core/slug.ts avec des tests complets dans slug.test.ts.
Structure d'URL
Chaque URL publique a la forme /<path> où <path> est calculé depuis un petit set de règles :
| Entité | Path URL |
|---|---|
| Home | /index.html (existe toujours, même quand la home est une page statique) |
| Page top-level | /<page-slug>.html |
| Post avec catégorie principale | /<category-slug>/<post-slug>.html |
| Post sans catégorie principale | /<post-slug>.html |
| Archive de catégorie | /<category-slug>/index.html |
| Fallback 404 | /404.html |
| Tag | (pas d'URL) |
Les tags n'apparaissent jamais dans les URLs. C'est de la métadonnée pour le filtrage et l'affichage, pas pour le routage.
Slug rules
Chaque slug doit matcher [a-z0-9-]+ :
- Lettres ASCII minuscules + chiffres + tirets uniquement
- Pas de tiret en début ou fin
- Pas de double-tirets
- Pas d'espaces, slashes, points, accents, caractères spéciaux
slugify(input) normalise toute chaîne arbitraire vers un slug valide. isValidSlug(slug) est le validateur.
URLs canoniques
Les URLs canoniques (pour <link rel="canonical"> et les sitemaps) suppriment /index.html en fin :
- Home :
/ - Archive :
/<slug>/
Les deux formes (avec et sans index.html) résolvent vers le même fichier sur Flexweg, mais la forme à slash final est la canonique préférée par les moteurs de recherche.
Le helper canonicalUrl(baseUrl, path) fait cette normalisation. Voir Référence Runtime API.
Détection de collision
Quand l'utilisateur tape un slug dans l'éditeur, l'admin vérifie en temps réel via detectPathCollision(candidatePath, allEntities, ignoreId?).
La détection compare sur le chemin URL final, pas sur le slug brut. Donc :
- Un slug de post
newsdans la catégoriegeneral(URLgeneral/news.html) ne collisionne PAS avec un slug de catégorienews(URLnews/index.html) - Un slug de post sans catégorie
about(URLabout.html) collisionne avec un slug de pageabout(URLabout.html)
Cas particulier : la home page
/index.html est réservé. Si vous mettez le mode home en static-page et désignez une page comme home, cette page :
- Garde son slug normal en base
- Mais est publiée à
/index.html(à la place de<slug>.html) - L'admin gère le cleanup de l'ancien
<slug>.htmlà la transition
Cas particulier : /404.html
Réservé pour la page 404. Aucun post / page ne peut avoir 404 comme slug.
Auto-resolution sur collision
findAvailableSlug(candidate, existing, ignoreId?) ajoute -2, -3, etc. jusqu'à trouver un libre :
findAvailableSlug("hello-world", existingPosts)
// → "hello-world" si libre
// → "hello-world-2" si pris
// → "hello-world-3" si "-2" aussi pris
// etc.
Utilisé pour les slugs auto-générés depuis le titre. Pour les slugs saisis manuellement par l'utilisateur, l'admin affiche une erreur inline et désactive le bouton Sauvegarder — pas d'auto-resolve.
L'invariant ignoreId
Quand vous validez le slug d'un post existant, vous devez passer son id comme ignoreId pour qu'il ne collisionne PAS avec lui-même :
const colliding = detectPathCollision(candidatePath, allPosts, post.id);
Sans cet ignoreId, le post se voit comme une collision contre lui-même → l'utilisateur voit une erreur trompeuse.
Le cycle de vie d'un slug
- Création : le slug est calculé depuis le titre via
slugify(title)puis désambiguïsé viafindAvailableSlug. Le slug est stocké en base. - Édition : l'utilisateur peut taper un slug manuellement. La validation court-circuite l'auto-resolve. L'admin émet une erreur inline si collision.
- Publication :
buildPostUrl({ post, primaryTerm })calcule l'URL finale. Le HTML est uploadé à cette URL. - Changement de slug : à la prochaine publication, l'ancien fichier est supprimé, le nouveau est uploadé. Voir Nettoyage des chemins stales.
Race entre auto-gen et hydrate
Dans PostEditPage, deux useEffect tournent en parallèle :
- L'effet d'hydratation : charge le post existant et set le titre + slug initialement
- L'effet d'auto-gen : recalcule le slug depuis le titre quand
slugDirty === false
Ces deux effets peuvent voir des états stale l'un de l'autre dans le même cycle de render. Sans précaution, l'auto-gen lit title="" / slugDirty=false et écrase le slug hydraté avec le slug d'un titre vide.
Le fix : un justHydratedRef que l'effet d'hydratation set et que l'auto-gen consomme une fois. Bloc le cycle de race.
Race entre auto-gen et save
Quand l'utilisateur clique Save :
setSlugDirty(true)est appelé IMMÉDIATEMENT (avant tout async)addOptimisticPostadd l'entrée àpostslocalement (avant le serveur ne réponde)- Le post a maintenant un nouvel id mais
existing?.idest encore undefined - Le check de collision voit
postscontenant l'entrée fraîchement ajoutée → trouve une "collision" → auto-bump à-2
Le fix : verrouiller slugDirty=true au début de handleSave. Cela short-circuit l'auto-gen pour le reste du save flow.
Slug et i18n
Le slugify est ASCII-only par défaut. Pour traduire un titre en slug FR :
slugify("À propos") // → "a-propos"
slugify("Café & Cocktails") // → "cafe-cocktails"
Les accents sont neutralisés via normalize("NFD").replace(/[\u0300-\u036f]/g, "").
Pour des langues non-latines (japonais, arabe), le slugify produit un slug vide ou trop court. Dans ce cas, l'utilisateur doit taper un slug manuel — l'auto-gen est laissé à la traduction romanisée.
Le normalizeMediaSlug
Pour les médias, le slug a un suffixe hex 6 caractères :
normalizeMediaSlug("photo") // → "photo-fxr8sd"
Garantit l'absence de collision pour les médias. Aucun écrasement possible même avec le même nom de fichier uploadé deux fois.
Tests
slug.test.ts couvre :
slugifysur des entrées variées (Unicode, accents, ponctuation, casse mixte)isValidSlugcases passantes et failuresdetectPathCollisionsur les patterns d'URLfindAvailableSlugjusqu'à-10canonicalUrlavec et sans/index.html
À lancer avec npm test -- slug.test.ts.