Files
thalim-stack/NOTE-SLUG-NUMERIQUE-404-2026-08-27.md
valentin_le_moignandClaude Opus 5 e64a44a275 Notes des deux correctifs du 2026-08-27 + CLAUDE.md
- NEWSLETTER-RETOURS-2026-08-27.md : retour du commanditaire sur la newsletter
  de juillet (annonces à venir absentes, dates de publication affichées à la
  place des dates d'événement), vérification dans le code et implémentation.
- NOTE-SLUG-NUMERIQUE-404-2026-08-27.md : annonces publiées répondant 404
  (slug numérique incompatible avec /%category%/%postname%/), diagnostic et
  implémentation.
- CLAUDE.md : nouveau module inc/post-slug-guard.php, piège du titre vide dans
  post-title-required.php, et bascule de la newsletter sur la date d'événement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w7RzAHv4r1Yahnm2NwDDG
2026-08-27 17:48:18 +02:00

11 KiB

Annonces en 404 : slugs numériques et permaliens /%category%/%postname%/

Note rédigée le 2026-08-27, avant toute modification du code. Origine : signalement du commanditaire — une annonce publiée, visible dans les listes, renvoie un 404 au clic. Cas de référence en prod : « Charte d'usage des moyens informatiques du CNRS », permalien https://thalim.cnrs.fr/le-laboratoire/vie-du-labo-intranet/44210/. Deuxième cas confirmé en prod : l'annonce dont le titre est « 1917 ».

1. Symptôme

L'annonce est bien publiée. Elle apparaît normalement dans les index (listes de catégorie, agenda, recherche) parce que ces écrans manipulent l'objet post. Mais son permalien n'est pas routable : au clic, WordPress rend le 404 du thème (templates/404.twig). L'URL courte ?p={ID} ne sauve pas la situation — elle fait un 301 vers ce même permalien cassé.

2. Diagnostic

La structure de permaliens du site est /%category%/%postname%/. La règle de réécriture qui sert les articles est :

(.+?)/([^/]+)(?:/([0-9]+))?/?$   =>   index.php?category_name=$1&name=$2&page=$3

Le dernier groupe est un numéro de page (pagination interne d'un article via <!--nextpage-->). Conséquence : quand le slug d'un article est purement numérique, il est avalé comme numéro de page, et le segment précédent — qui est en réalité la sous-catégorie — est pris pour le slug de l'article.

Reproduit en local sur l'article ID 30497 (titre « 1917 », slug 1917) :

URL demandée  : /manifestations-scientifiques/communications/1917/   → HTTP 404
règle matchée : (.+?)/([^/]+)(?:/([0-9]+))?/?$
query_vars    : {"category_name":"manifestations-scientifiques",
                 "name":"communications",   ← pris pour le slug de l'article
                 "page":"1917"}             ← pris pour un numéro de page

WordPress cherche donc un article nommé communications dans la catégorie manifestations-scientifiques, page 1917. Il n'existe pas → 404. Le cas /le-laboratoire/vie-du-labo-intranet/44210/ est exactement le même.

3. Deux causes distinctes, un seul symptôme

Cause A — titre vide au moment de la première publication (cas « Charte d'usage »)

Cœur de WordPress, wp-includes/post.php:4954 :

if ( empty( $data['post_name'] ) && ! in_array( $data['post_status'], array( 'draft', 'pending', 'auto-draft' ), true ) ) {
    $data['post_name'] = wp_unique_post_slug( sanitize_title( $data['post_title'], $post_id ),  );
}

sanitize_title( $titre, $fallback ) renvoie le $fallback — donc l'ID du post — quand le titre est vide. Le slug est figé à cet instant, et n'est jamais régénéré ensuite (la condition exige post_name vide) : corriger le titre et republier ne change rien.

Le chemin qui produit ça est notre propre garde-fou. inc/post-title-required.php le dit dans son en-tête : « the post saves (with empty title), the status is reverted to draft if needed ». Séquence :

  1. clic sur Publier avec le champ Titre vide ;
  2. wp_insert_post écrit la ligne en publishslug figé à l'ID ;
  3. save_post (priorité 5) détecte le titre vide, pose le transient de restauration ;
  4. redirect_post_location (inc/pods-save-error-handler.php:56-73) remet le statut à draft et renvoie vers l'écran d'édition avec le message « Le champ Titre est obligatoire » ;
  5. l'utilisateur saisit le titre et republie — le slug numérique reste.

Le même scénario existe via la validation Pods (inc/pods-save-error-handler.php) si un champ obligatoire manque et que le titre est vide à ce premier enregistrement.

À noter : ce n'est pas un doublon de titre. Un doublon produirait charte-dusage-…-2, jamais l'ID.

Cause B — titre légitimement numérique (cas « 1917 »)

Aucun bug ici : le titre « 1917 » donne le slug 1917, qui est correct du point de vue de WordPress mais irroutable avec cette structure de permaliens. Cette cause est indépendante de la cause A et survivra à sa correction. Elle touche aussi tout contenu entré par import SQL direct ou par l'importateur HAL.

4. Correctif proposé — deux volets

Les volets A et B empêchent le problème de réapparaître. Les deux articles déjà touchés se corrigent à la main (§5) : leur nombre étant connu et limité à deux, un rattrapage automatique au routage n'est pas justifié.

Volet A — interdire la naissance de slugs purement numériques

Nouveau module inc/post-slug-guard.php, chargé depuis functions.php juste après post-title-required.php.

/**
 * Un slug purement numérique est irroutable avec la structure
 * /%category%/%postname%/ : le dernier segment est avalé comme numéro de page
 * par la règle (.+?)/([^/]+)(?:/([0-9]+))?/?$. On suffixe donc ces slugs,
 * en suivant la convention du cœur (-2, -3, …).
 *
 * Portée volontairement limitée à post/page : nav_menu_item et attachment
 * ont légitimement des slugs numériques (38 et 4 occurrences en base de dev)
 * et ne passent pas par cette règle de réécriture.
 */
add_filter( 'wp_unique_post_slug', function ( $slug, $post_id, $post_status, $post_type ) {
    if ( ! in_array( $post_type, [ 'post', 'page' ], true ) || ! ctype_digit( (string) $slug ) ) {
        return $slug;
    }
    global $wpdb;
    $suffix = 2;
    do {
        $alt    = $slug . '-' . $suffix;
        $exists = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1",
            $alt, $post_type, $post_id
        ) );
        $suffix++;
    } while ( $exists );
    return $alt;
}, 10, 4 );

Effet : un futur article intitulé « 1917 » obtient 1917-2, routable. Limite connue : un import SQL direct contourne wp_insert_post, donc ce filtre — après toute reprise de données massive (migration, importateur HAL), rejouer la requête d'inventaire du §5.

Volet B — ne pas figer un slug issu d'un titre vide

Dans inc/pods-save-error-handler.php, bloc d'annulation du statut (l. 63-72), vider post_name uniquement s'il est numérique — c'est-à-dire s'il a été généré faute de titre. Un slug saisi à la main sur un brouillon est ainsi préservé.

$update  = [ 'post_status' => $original ?: 'draft' ];
$formats = [ '%s' ];

// Slug figé à l'ID faute de titre : le vider pour qu'il soit régénéré
// depuis le vrai titre à la prochaine publication.
if ( ctype_digit( (string) $post->post_name ) ) {
    $update['post_name'] = '';
    $formats[]           = '%s';
}

$wpdb->update( $wpdb->posts, $update, [ 'ID' => $post_id ], $formats, [ '%d' ] );

Effet : le scénario « Charte d'usage » ne peut plus se produire — au moment où l'utilisateur corrige son titre et republie, WordPress régénère un slug propre.

5. Remise en état des deux articles existants

Les volets A et B n'agissent qu'à la création du slug : ils ne réparent pas les deux articles déjà en 404. Il faut donc éditer chacun d'eux et corriger le permalien à la main :

Article Slug actuel Slug à poser
Charte d'usage des moyens informatiques du CNRS 44210 charte-dusage-des-moyens-informatiques-du-cnrs
1917 1917 1917-2 (ou tout slug non purement numérique)

Les anciennes URL numériques étant déjà en 404, ce changement ne casse aucun lien existant. À faire après le déploiement du volet A, sans quoi rien ne garantit qu'un futur enregistrement ne régénère pas un slug numérique.

Inventaire des contenus concernés, exécuté en production le 2026-08-27 :

SELECT ID, post_type, post_status, post_name, post_title
FROM wp_posts
WHERE post_type IN ('post','page')
  AND post_name REGEXP '^[0-9]+$'
ORDER BY post_type, ID;

Résultat : 2 articles seulement, les deux déjà connus — « Charte d'usage des moyens informatiques du CNRS » (slug 44210) et « 1917 » (slug 1917). Le périmètre de rattrapage est donc entièrement circonscrit. Les nav_menu_item et attachment à slug numérique sont normaux et hors périmètre (le volet A ne les touche pas).

6. Éléments pour la réponse au commanditaire

  • L'annonce est bien publiée, rien n'est perdu ; c'est son adresse qui est invalide, d'où l'incohérence « visible dans la liste, 404 au clic ».
  • Cause : l'annonce a été publiée une première fois alors que le champ Titre était encore vide. WordPress a alors fabriqué son adresse à partir de son numéro interne, et ne la régénère plus ensuite, même après correction du titre.
  • Ce n'est pas lié à un doublon de titre.
  • Deux annonces sont concernées sur tout le site, la leur et une autre : elles sont réparées à la main en corrigeant leur permalien, et redeviennent accessibles immédiatement.
  • Un correctif côté code est prévu pour que le cas ne se reproduise plus, sans intervention de leur part.
  • Consigne utile en attendant ce correctif : saisir le titre avant le premier enregistrement.

7. Contexte technique

  • WordPress 7.0, structure de permaliens /%category%/%postname%/.
  • Règle de réécriture concernée : rang 111 sur 123 ((.+?)/([^/]+)(?:/([0-9]+))?/?$).
  • Vérifications faites sur l'environnement de dev local (dump au 2026-07-03), conteneurs thalim-dev-db-1 et wordpress.
  • Constat annexe repéré au passage : il existe des articles publiés partageant le même post_name, ce qui est impossible via l'API WordPress — trace d'imports SQL directs (migration / importateur HAL). Sans rapport avec ce 404, mais un lien peut y renvoyer vers le mauvais article. À traiter séparément.

8. Implémentation — 2026-08-27

Faite dans thalim-theme, conforme aux volets A et B ci-dessus.

  • Volet A — nouveau module inc/post-slug-guard.php, chargé depuis functions.php après post-title-required.php. Filtre wp_unique_post_slug limité à post/page, plus le helper pur thalim_slug_needs_guard(). Vérifié : 19171917-2, 4421044210-2, 99999999-2 ; charte-dusage-… inchangé ; 99999 en nav_menu_item et en attachment inchangé.
  • Volet B — dans inc/pods-save-error-handler.php, le bloc d'annulation de statut vide aussi post_name. Écart assumé avec la note : la condition n'est pas « slug numérique » mais « titre vide ». Le volet A transforme en effet 44210 en 44210-2, qui n'est plus numérique — la condition d'origine n'aurait donc jamais déclenché une fois les deux volets en place. Tester le titre vide vise directement la cause et reste juste dans tous les cas. Vérifié de bout en bout : publication sans titre → slug 44021-2 ; annulation du statut → draft + slug vidé ; saisie du titre puis publication → slug charte-d-usage-des-moyens-informatiques-du-cnrs, permalien routable. Le post de test a été supprimé définitivement.
  • Non fait : la remise en état des deux articles existants (§5), qui reste à faire à la main en prod après déploiement.