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><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 news dans la catégorie general (URL general/news.html) ne collisionne PAS avec un slug de catégorie news (URL news/index.html)
  • Un slug de post sans catégorie about (URL about.html) collisionne avec un slug de page about (URL about.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 :

TS
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 :

TS
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

  1. Création : le slug est calculé depuis le titre via slugify(title) puis désambiguïsé via findAvailableSlug. Le slug est stocké en base.
  2. Édition : l'utilisateur peut taper un slug manuellement. La validation court-circuite l'auto-resolve. L'admin émet une erreur inline si collision.
  3. Publication : buildPostUrl({ post, primaryTerm }) calcule l'URL finale. Le HTML est uploadé à cette URL.
  4. 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 :

  1. setSlugDirty(true) est appelé IMMÉDIATEMENT (avant tout async)
  2. addOptimisticPost add l'entrée à posts localement (avant le serveur ne réponde)
  3. Le post a maintenant un nouvel id mais existing?.id est encore undefined
  4. Le check de collision voit posts contenant 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 :

TS
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 :

TS
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 :

  • slugify sur des entrées variées (Unicode, accents, ponctuation, casse mixte)
  • isValidSlug cases passantes et failures
  • detectPathCollision sur les patterns d'URL
  • findAvailableSlug jusqu'à -10
  • canonicalUrl avec et sans /index.html

À lancer avec npm test -- slug.test.ts.