Aller au contenu

ADR-017 — Type slug et module URL-slug canonique

Statut

Acceptée

Décision-cadre de la Phase 2 de la roadmap beta.13. Implémentée par SLUG-CORE-001 (module), SLUG-VALIDATION-001, SLUG-SQL-CRUD-001, SLUG-ROUTING-001.


Contexte

Les URLs publiques générées par Forge restent faibles sans slug (/articles/42 plutôt que /articles/premier-contact). Le slug est nécessaire pour les pages publiques, catalogues, blogs : URLs lisibles, partageables, SEO-friendly.

L'audit de l'existant (BETA13-EXISTING-AUDIT-001) révèle un terrain partiellement défriché mais incohérent :

  • forge_cli/public_page._slugify — un validateur strict (kebab-case, [a-z0-9-]) qui rejette les accents (ValueError sur « Écrire ») ; ce n'est pas un générateur. 1 seul usage.
  • forge_cli/entities/migrations.slugify_migration_name — un transformeur snake_case (_) pour les noms de fichiers de migration : concept différent d'une URL slug (format et finalité distincts).
  • type slug déjà reconnu dans le contrat d'entité (forge_cli/entities/validation.py) + SlugField dans le CRUD, sans logique de génération.

Trois objets « slug » coexistent donc, avec des contrats divergents — risque de duplication (charte §11) si on ajoute naïvement une 4ᵉ fonction.


Décision

D1 — Deux concepts distincts, nommés clairement

  • URL slug (kebab-case -, pour les URLs publiques) → un seul module canonique core/http/slug.py.
  • Nom de fichier de migration (snake_case _) → reste slugify_migration_name, séparé. Ce n'est pas une URL slug ; un commentaire le précise pour éviter toute fusion future.

D2 — Module canonique core/http/slug.py (runtime, stdlib seul)

Deux fonctions publiques, dépendances stdlib uniquement (unicodedata, re) — respect du runtime minimal (charte) :

def slugify(text: str, *, max_length: int = 180) -> str
def is_valid_slug(value: str, *, max_length: int = 180) -> bool
  • slugify transforme n'importe quel texte en URL slug : translittération des accents via unicodedata.normalize("NFKD", …) (« Écrire avec Forge ! » → ecrire-avec-forge), minuscules, tout caractère non [a-z0-9]-, tirets compactés, bordures retirées, longueur bornée. Ne lève que si le résultat est vide (entrée sans aucun caractère exploitable).
  • is_valid_slug valide un slug existant : path-safe (rejette /, \, ..), correspond à [a-z0-9]+(?:-[a-z0-9]+)*, longueur ≤ max_length.

Le runtime (contrôleurs/modèles générés) et le CLI (codegen) importent ce même module. forge_cli/public_page._slugify est remplacé par core.http.slug (§11 : une seule implémentation URL-slug).

D3 — Le type slug dans le contrat d'entité

slug est un type de champ de premier rang du contrat d'entité JSON (au même titre que string, email…), validé par le contrat (ADR-013) :

{ "name": "slug", "type": "slug", "required": true, "unique": true }

Sémantique : identifiant URL-safe d'une ressource. La génération SQL produit VARCHAR(180) + contrainte UNIQUE visible (charte « SQL visible »).

D4 — Unicité : rejet des doublons (pas de suffixe auto en b13)

Le CRUD généré refuse un slug en doublon avec une erreur claire. Le suffixe automatique (mon-article-2) introduit une complexité de concurrence (race sur insert) non justifiée en dernière beta → reporté après 1.0.

D5 — Périmètre b13

Inclus : module canonique, validation, type d'entité, génération SQL/CRUD, routing public par slug (SLUG-ROUTING-001). Exclus (post-1.0) : suffixe auto, slug_history, redirections 301, sitemap, slugs multilingues.


Conséquences

  • public_page._slugify change de comportement : il translittère les accents au lieu de les rejeter (plus utile) — ses tests sont ajustés.
  • slugify_migration_name est inchangé mais documenté comme distinct.
  • Le contrat d'entité gagne un type de premier rang slug → garde-fou de complétude (§10) : SQL, validation, CRUD, doc cohérents.
  • Nouveau module runtime core/http/slug.py testé indépendamment.

Alternatives considérées

Fusionner les 3 « slug » en une fonction. Écarté : slugify_migration_name produit du snake_case pour des noms de fichiers, pas des URLs — fusionner casserait les migrations. Les deux concepts restent séparés (D1).

Option « slug » comme simple drapeau sur un champ string ({"type": "string", "slug": true}). Écarté au profit d'un type dédié (D3) : plus explicite pour les générateurs, les tests et la doc, conforme à « une seule façon » (§11).

Dépendance externe (python-slugify, unidecode). Écarté : unicodedata (stdlib) suffit pour la translittération NFKD ; le runtime minimal de Forge n'accueille pas de dépendance pour ça.

Suffixe automatique -2/-3 dès b13. Écarté : race conditions sur insert concurrent, complexité non justifiée en dernière beta (D4).


Liens

  • Charte : §10 (contrat de complétude), §11 (une seule façon), « SQL visible », runtime minimal.
  • ADR-013 — politique du contrat d'entité (nullable/required).
  • Roadmap beta.13 — Phase 2.