# Schéma du blog multilingue

Quatre tables, plus celle de `spatie/laravel-medialibrary`. Migration :
`database/migrations/2026_07_31_120000_create_blog_tables.php`.

```
categories                      posts                          media (medialibrary)
  id                              id                             model_type = App\Models\Post
  position                        category_id ──► nullOnDelete   model_id
  timestamps                      author_id   ──► users, null    collection_name = cover | og
     │                            status                         …
     │ cascade                    published_at
     ▼                            reading_time
category_translations             timestamps
  id                                 │ cascade
  category_id                        ▼
  locale                          post_translations
  name                              id
  slug          unique(locale,slug)  post_id
  description                       locale
  meta_title                        title
  meta_description                  slug          unique(locale,slug)
  timestamps                        excerpt
  unique(category_id, locale)       body
                                    meta_title
                                    meta_description
                                    meta_robots
                                    timestamps
                                    unique(post_id, locale)
```

---

## Le choix structurant : des tables de traduction séparées

Le contenu traduisible ne vit pas dans des colonnes JSON mais dans des lignes
`*_translations`, une par langue. Ce n'est pas un réflexe d'ORM, c'est ce qui rend
possible le comportement attendu côté public :

- **résoudre un slug par langue est une requête indexée.** `unique(locale, slug)` porte
  la liaison de route ; avec des colonnes JSON, il faudrait un balayage ou un index
  fonctionnel propre à MySQL ;
- **l'absence de ligne est une information.** Un article non traduit en espagnol n'a pas
  de ligne `es` : le listing espagnol l'ignore, l'URL espagnole rend un vrai **404**, et
  aucun `hreflang` ne le désigne. Avec un JSON à clé manquante, il aurait fallu
  distinguer « clé absente » de « valeur vide » partout.

**L'existence de la ligne EST le critère de mise en ligne dans cette langue.** Cette
phrase gouverne tout le reste : le back-office (vider un titre supprime la traduction),
le front (`scopePublishedIn`), le sitemap et le sélecteur de langue.

---

## `posts`

| Colonne | Type | Rôle |
|---|---|---|
| `category_id` | FK nullable, `nullOnDelete` | supprimer une catégorie **déclasse** ses articles, elle ne les supprime pas |
| `author_id` | FK `users` nullable, `nullOnDelete` | un compte retiré ne fait pas disparaître ses articles |
| `status` | `varchar(20)`, défaut `draft` | `App\Enums\PostStatus` : `draft` \| `published` |
| `published_at` | `timestamp` nullable | date de mise en ligne ; **une date future programme la publication** |
| `reading_time` | `smallint` nullable | minutes, saisi à la main |

Index `(status, published_at)` : c'est celui que consomme `scopePublished()` et le tri du
listing.

`status` est un `varchar` et non un `enum` MySQL : un enum n'existe pas en SQLite (base
de test) et son évolution exige `doctrine/dbal`.

### La publication programmée ne demande aucune tâche planifiée

```php
public function scopePublished(Builder $query): Builder
{
    return $query->where('status', PostStatus::Published)
        ->whereNotNull('published_at')
        ->where('published_at', '<=', now());
}
```

Un article `published` daté dans le futur est simplement exclu par la comparaison, à
chaque requête. Il apparaît de lui-même à l'heure dite. Aucun cron n'est nécessaire — ce
qui compte sur un serveur qui n'en a pas.

**Corollaire à connaître :** un article `published` **sans** `published_at` n'est jamais
visible. Le formulaire du back-office rend donc la date obligatoire dès que le statut
passe à « Publié ».

---

## `post_translations`

| Colonne | Type | Note |
|---|---|---|
| `locale` | `varchar(5)` | `fr`, `en`, `es` |
| `title` | `varchar(255)` | |
| `slug` | `varchar(191)` | segment d'URL, unique par langue |
| `excerpt` | `text` | chapô : listing, et repli de `meta_description` |
| `content` | `json` | **contenu en blocs** — la source de vérité |
| `search_text` | `text` | aplatissement des blocs en texte brut, pour la recherche |
| `body` | `longtext` | HTML hérité, **assaini à l'écriture**. Rendu uniquement si `content` est vide |
| `meta_title` | `varchar(255)` | vide → le titre |
| `meta_description` | `varchar(500)` | vide → le chapô tronqué à 155 caractères |
| `meta_robots` | `varchar(50)` | à ne renseigner que pour désindexer |

`slug` en `varchar(191)` : `(locale 5 + slug 191) × 4 octets = 784 octets`, très en
dessous de la limite de préfixe d'index d'InnoDB, y compris sur une instance configurée à
l'ancienne.

Deux contraintes d'unicité, et elles ne disent pas la même chose :

- `unique(post_id, locale)` — une seule traduction par langue et par article ;
- `unique(locale, slug)` — une seule URL par langue. Le même slug reste possible en
  français et en anglais.

### Le contenu est une liste de blocs typés

```json
[
  {"type": "text",       "data": {"body": "<h2>…</h2><p>…</p>"}},
  {"type": "image",      "data": {"path": "blog/content/a.jpg", "alt": "…", "caption": "…", "width": "wide"}},
  {"type": "image_text", "data": {"path": "…", "alt": "…", "position": "left", "body": "<p>…</p>"}},
  {"type": "quote",      "data": {"text": "…", "author": "…"}},
  {"type": "gallery",    "data": {"images": [{"path": "…", "alt": "…", "caption": "…"}]}}
]
```

Pourquoi des blocs et non du HTML libre : l'éditeur riche de Filament s'appuie sur
**Trix**, qui sait insérer une image dans le flux mais ne sait pas placer du texte **à
côté** d'une image. Obtenir cette mise en page en HTML libre supposerait d'autoriser des
`<div>` et des classes de mise en page dans un contenu saisi par l'équipe éditoriale —
donc d'ouvrir l'assainissement, et de laisser la mise en page casser en responsive au
premier copier-coller. Un bloc porte une **intention** (« image à gauche, texte à
droite ») ; le gabarit Blade décide de la traduire en une grille qui s'empile sur mobile.

Un type inconnu est **écarté à l'écriture** (`App\Support\BlogBlocks`), et le rendu
revérifie l'existence du gabarit : une donnée injectée par un autre chemin ne peut pas
faire inclure une vue arbitraire.

Gabarits : `resources/views/blog/blocks/*.blade.php`, distribués par `blog/_blocks`.

### Trois colonnes, parce qu'elles n'ont pas le même besoin d'échappement

| Colonne | Rôle | Échappement |
|---|---|---|
| `content` | source de vérité | JSON ; le texte riche des blocs est assaini |
| `search_text` | LIKE de `Post::scopeSearch()` | texte **décodé** — `l&#039;atelier` ne correspondrait pas à une recherche sur « l'atelier » |
| `body` | rendu des articles hérités ou importés | HTML échappé, rendu en `{!! !!}` |

Réutiliser `body` comme texte de recherche a été essayé puis abandonné : une colonne ne
peut pas être à la fois du HTML sûr à rendre et du texte décodé à chercher. Les séparer
garantit en prime qu'un texte de recherche ne sera jamais rendu par erreur. L'écriture des
blocs ne touche donc **jamais** `body`.

Deux pièges de l'aplatissement, tous deux trouvés en relisant la sortie réelle et
désormais couverts par des tests :

- `strip_tags()` **colle** les blocs : `<h2>Intertitre</h2><p>Du texte` devenait
  « IntertitreDu texte », et chercher « intertitre » ne remontait rien. Les balises sont
  remplacées par une espace, pas supprimées ;
- les **entités survivent** à l'assainissement. Elles sont décodées avant stockage.

### `body` est assaini à l'écriture, pas à l'affichage

`PostTranslation::setBodyAttribute()` passe systématiquement par `App\Support\BlogHtml`.
Le HTML stocké est donc sûr par construction, quel que soit le chemin qui l'a produit
(back-office, import, tinker). Assainir à l'affichage aurait laissé du HTML hostile en
base, prêt à ressortir par le premier chemin qui oublierait de filtrer — flux RSS, export,
aperçu, API.

Conséquence assumée : **ce qui est retiré est perdu.** Le compromis est le bon pour un
back-office interne dont l'éditeur ne produit qu'un sous-ensemble restreint de balises.

Deux détails que seule l'écriture des tests a révélés :

- le sanitizer Symfony **tronque à 20 000 caractères sans erreur** ; `BlogHtml::MAX_LENGTH`
  relève la limite à 500 000 ;
- `blockElement()` conserve le texte de la balise retirée — un `<script>alert(1)</script>`
  ressortait en « alert(1) » affiché en clair. C'est `dropElement()` qu'il faut.

---

## `categories` / `category_translations`

Les catégories sont **facultatives** : un article sans catégorie est publié normalement.

`position` (entier, indexé) donne l'ordre du filtre sur le listing ; il est réordonnable
à la souris dans le back-office. Le tri ne dépend donc pas d'un nom traduit, qui
changerait d'ordre selon la langue.

`category_translations` porte `name`, `slug`, `description` et deux champs SEO. Le `slug`
est ce qui apparaît dans `/{locale}/conseils?category=<slug>`, résolu **dans la langue
courante** : un slug d'une autre langue rend 404, pas la liste complète (un « soft 404 »
serait indexé puis pénalisé).

---

## Médias

Deux collections à fichier unique sur `Post`, via medialibrary :

| Collection | Usage |
|---|---|
| `cover` | illustration éditoriale : listing et article |
| `og` | image de partage, facultative — vide, `cover` est utilisée |

Deux collections et non une : un visuel cadré en 16/9 se fait couper la tête sur les
1200 × 630 qu'attendent Facebook et LinkedIn.

Conversions WebP, **toutes en `nonQueued()`** : `thumb` 400×250, `card` 800×500,
`hero` 1600×900, `og` 1200×630. Il n'y a pas de worker sur ce serveur
(`QUEUE_CONNECTION=sync`) : une conversion mise en file ne serait jamais exécutée et
l'article s'afficherait sans image, sans la moindre erreur. Le prix est de quelques
secondes à l'enregistrement.

Les fichiers vivent sur le disque `public` (`storage/app/public`), servis par le lien
symbolique `public/storage`.

---

## Ce que le schéma garantit, et qui est testé

`tests/Database/BlogConstraintTest` (suite MySQL) le vérifie sur un vrai moteur, parce
que SQLite est trop permissif pour prouver quoi que ce soit ici :

| Garantie | |
|---|---|
| `unique(locale, slug)` et `unique(post_id, locale)` réellement appliquées | ✅ |
| suppression d'un article ⇒ ses traductions partent en cascade | ✅ |
| suppression d'une catégorie ⇒ `category_id` passe à `null`, l'article survit | ✅ |
| référence orpheline (`category_id` inexistant) refusée | ✅ |
| `published_at` relu sans décalage de fuseau | ✅ |

`tests/Database/MysqlSchemaTest` vérifie en plus les types réels côté MySQL : `body` en
`longtext`, slugs en `varchar(191)`, tables en InnoDB/utf8mb4, index présents.

---

## Requêtes utiles

```php
// Articles en ligne et traduits dans la langue courante
Post::publishedIn()->with('translations')->latest('published_at')->get();

// Langues réellement accessibles d'un article, dans un ordre stable
$post->publishedLocales();          // ['fr', 'en'] — ordre de supported_locales

// Traduction d'une langue, sans repli
$post->translationFor('es');        // null si non traduit
```

```sql
-- Articles publiés mais sans date : invisibles, et rien ne le signale côté base
SELECT id FROM posts WHERE status = 'published' AND published_at IS NULL;

-- Articles incomplets (moins de 3 langues) — le filtre « traduction manquante »
SELECT p.id, count(t.id) AS langues
FROM posts p LEFT JOIN post_translations t ON t.post_id = p.id
GROUP BY p.id HAVING langues < 3;
```

---

## Voir aussi

- `doc/back-office.md` — comment ces tables sont éditées
- `doc/mysql.md` — bascule et pièges du moteur
