Créer un module
139 vues · 07/10/2026 15:23 Historique

Créer un module

Un module est une fonctionnalité complète : ses pages publiques, ses routes, son administration, ses données et ses permissions. C'est l'addon le plus riche.

Nous allons créer un module notes qui affiche une liste de notes publiques. Tout ce qui suit est vérifié contre le code ; les modules livrés (modules/contact, modules/news) sont les meilleurs exemples à lire ensuite.

Structure des fichiers

modules/notes/
├── notes.php                 # classe : métadonnées, déclarations, routes, permissions
├── controllers/
│   ├── checker.php           # valide la route et charge les données
│   ├── index.php             # rend les pages publiques
│   └── admin.php             # interface d'administration (optionnel)
├── install/
│   ├── install.sql           # tables du module (joué à l'installation, idempotent)
│   ├── uninstall.sql         # suppression des tables
│   └── migrations/           # évolutions du schéma entre deux versions (optionnel)
├── views/
│   └── index.tpl.php
├── css/                      # optionnel
├── images/
│   └── thumbnail.jpg         # sa vignette, 960 × 600 — une carte : son icône, son nom, ce qu'il fait
├── js/                       # optionnel — vanilla, jamais jQuery
└── langs/
    └── fr.php                # traductions (optionnel)

1. La classe — notes.php

<?php
namespace NF\Modules\Notes;

use NF\NeoFrag\Addons\Module;

class Notes extends Module
{
    protected function __info()
    {
        return [
            'title'       => $this->lang('Notes'),
            'description' => $this->lang('Petites notes publiques.'),
            'icon'        => 'fas fa-note-sticky',   // doit exister dans le FontAwesome embarqué
            'author'      => 'Ton Nom',
            'license'     => 'LGPLv3',
            'version'     => '1.0.0',
            'depends'     => ['neofrag' => '1.2.50'],   // le cœur qu'il demande au moins — sans lui, « Ajouter » refuse l'archive
            'admin'       => TRUE,                   // expose une page d'administration

            // Déclarations de découplage — OBLIGATOIRES (la CI refuse un addon muet).
            'core'        => FALSE,                  // TRUE = livré toujours et non désinstallable
            'presets'     => ['communaute'],         // profils d'installation qui le pré-cochent
            'requires'    => [],                     // addons dont il a BESOIN (sans eux, il casse)

            'routes'      => [
                ''                 => 'index',       // /notes
                '{id}/{url_title}' => '_show',       // /notes/42/ma-note
            ],
        ];
    }
}

Les trois déclarations de découplage

Depuis le 2026-09-15, le paquet s'installe à la carte : l'installeur propose des profils (Complet, Gaming / eSport, Communauté, Association / club, Cœur seul) composés à partir de ces déclarations. Aucune liste n'est écrite à la main ailleurs.

  • core — TRUE pour un module d'infrastructure ou de CMS livré toujours et non désinstallable ; FALSE pour tout ce qui est optionnel. Un module du cœur ne peut jamais dépendre d'un optionnel (règle 3 de tools/check-addon-declarations.php).
  • presets — les profils qui le pré-cochent : 'gaming', 'communaute', 'association' (les étiquettes de install/lib/presets.php). Vide : il n'apparaît que dans le profil Complet.
  • requires — les addons dont il a besoin pour ne pas casser : une table lue, une classe nommée. L'installeur les ajoute d'office quand on coche ton module. Une dépendance molle (un service qui peut manquer) ne se déclare pas ici : elle se garde dans le code (voir §5).

Ces trois clés sont vérifiées statiquement en CI, ainsi que l'existence de l'icône déclarée (règle 7 : FontAwesome a renommé des icônes entre la 5 et la 6). Un addon qu'on ne veut pas au catalogue du marketplace ajoute 'distributed' => FALSE.

Les routes

Une route mappe un motif d'URL vers une méthode de contrôleur. Le motif est relatif au nom du module ('' = la page d'accueil du module, ici /notes). Les routes d'administration commencent par admin ('admin{pages}' => 'index' pour une liste paginée sous /admin/notes).

Les placeholders sont un ensemble fixe : {id} (entier), {key_id}, {url_title} (slug), {url_title*} (slug avec des /), {page} et {pages} (pagination). Un placeholder inconnu produit un 404 silencieux. Pour une fiche, le motif idiomatique est {id}/{url_title} : l'id identifie la fiche, le slug est cosmétique.

2. Le checker — controllers/checker.php

Le checker s'exécute avant le contrôleur : il valide la route et charge les données. Renvoyer FALSE ou rien déclenche un 404. Ce qu'il retourne devient les arguments de la méthode de même nom du contrôleur.

<?php
namespace NF\Modules\Notes\Controllers;

use NF\NeoFrag\Loadables\Controllers\Module_Checker;

class Checker extends Module_Checker
{
    public function index()
    {
        $notes = $this->db->select('id', 'title', 'body')
                          ->from('nf_notes')
                          ->order_by('id DESC')
                          ->get();

        return [$notes];   // → index($notes)
    }

    public function _show($id, $url_title)
    {
        if ($note = $this->db->from('nf_notes')->where('id', $id)->row())
        {
            return [$note];   // → _show($note)
        }
        // rien : 404
    }
}

Pour un point d'entrée qui reçoit un POST (AJAX, formulaire écrit à la main), le checker lit les champs avec post_check('titre', 'corps', 'options?') : chaque nom est obligatoire, sauf s'il porte le suffixe ?, auquel cas il vaut NULL s'il manque. Un champ obligatoire absent fait échouer le checker : réponse 404 en production (le motif exact — quel champ, ce qui est arrivé à la place — est journalisé) et 400 avec le motif quand le débogage est visible (un administrateur connecté, l'outil de débogage allumé). Ce comportement vient de dix widgets qu'on ne pouvait plus ajouter dans l'éditeur en direct parce qu'un champ facultatif était exigé.

3. Le contrôleur public — controllers/index.php

<?php
namespace NF\Modules\Notes\Controllers;

use NF\NeoFrag\Loadables\Controllers\Module as Controller_Module;

class Index extends Controller_Module
{
    public function index($notes)
    {
        $this->title($this->lang('Notes'))
             ->icon('fas fa-note-sticky')
             ->breadcrumb();

        return $this->css('notes')->view('index', ['notes' => $notes]);
    }
}

title(), icon(), breadcrumb() renseignent l'en-tête de page. Le contrôleur rend ensuite sa vue, ou un panneau via $this->panel()->title()->body($html) pour du HTML construit en PHP.

Ne passe jamais du contenu de la base par $this->title() puis par la traduction. Un titre écrit par l'administrateur n'a pas de traduction : lang() le cherche en vain et journalise un avertissement à chaque visite dans une autre langue. Emballe ce qui vient de la base dans $this->no_translate(...).

4. La vue — views/index.tpl.php

<div class="notes">
    <?php if (empty($notes)): ?>
        <div class="alert alert-info"><?php echo $this->lang('Aucune note.') ?></div>
    <?php else: foreach ($notes as $n): ?>
        <article class="note">
            <h3><?php echo nf_texte($n['title']) ?></h3>
            <p><?php echo nf_texte($n['body']) ?></p>
        </article>
    <?php endforeach; endif ?>
</div>

Les variables passées à view() sont disponibles directement. Échappe toujours ce qui vient de la base (nf_texte()) ; pour du HTML riche saisi par un membre, sanitize_html() (HTMLPurifier). Une adresse saisie qui finit dans un href passe en plus par nf_url_sure(), qui refuse javascript: et les autres schémas exécutables (cf. Le framework).

Le front est Bootstrap 5, sans jQuery, sous une CSP stricte.

  • Les classes de grille s'écrivent col-12 col-lg-8, jamais col-8 seul (qui s'applique dès 0 px et casse le téléphone) ; tools/check-classes-bs4.php refuse les classes Bootstrap 4 disparues.
  • Un <script> inline dans une vue porte le nonce de la réponse, <script<?php echo nf_nonce_attr() ?>> — sans lui, le navigateur le refuse ; un fichier JS se charge par ->js('nom') (dossier js/ du module).
  • Écris le JavaScript avec les primitives du cœur — NF.ready, NF.data(el, 'clé'), NF.ajax({url, method, data, dataType}), NF.post(url, data), NF.setHtml, NF.insertHtml, NF.replaceHtml — jamais $(…) : jQuery n'est plus chargé, et tools/check-js-sources.php refuse tout appel. Les adresses dont le script a besoin se posent en data-* sur le balisage plutôt qu'en PHP interpolé dans le .js.

5. Les données

Un module livre ses tables dans install/install.sql (et leur suppression dans uninstall.sql). Ce SQL est joué à l'installation du module (assistant d'installation, scan de l'administration, ZIP du marketplace), idempotent grâce à CREATE TABLE IF NOT EXISTS :

-- modules/notes/install/install.sql
CREATE TABLE IF NOT EXISTS nf_notes (
    id    INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    body  TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Un contenu qui se traduit (un titre, une description) vit dans une table <table>_lang, une ligne par langue, lue par join_lang() et écrite par ecrire_lang() : Le framework.

Pas de tables propres ? Pas de install.sql. Un module qui réutilise des tables du cœur (par exemple nf_file via model2('file')) s'installe sans toucher au schéma.

Faire évoluer le schéma — migrations par addon

install.sql crée les tables manquantes mais ne modifie pas une table déjà présente chez les utilisateurs. Pour un changement de schéma entre deux versions de ton module :

modules/notes/install/migrations/2026_10_01_add_pinned.up.sql
ALTER TABLE nf_notes ADD COLUMN pinned TINYINT(1) NOT NULL DEFAULT 0;
  • Nom = préfixe daté (AAAA_MM_JJ_description), exécution dans l'ordre.
  • Suivi dans nf_addon_migrations : chaque migration ne s'exécute qu'une fois.
  • À l'installation neuve (install.sql porte déjà le schéma à jour), les migrations sont baselinées — marquées sans être jouées. À la mise à jour (marketplace → « Mises à jour », ou une archive envoyée par « Ajouter »), seules les nouvelles sont exécutées (Addon::update()). Un module livré avec le cœur reçoit les siennes au premier passage du code neuf (nf_migrations_du_code(), cf. le framework).
  • Règle d'or : une migration pour une vraie évolution d'un schéma déjà livré ; une nouvelle table va dans install.sql, jamais dans une migration — sauf si le module est déjà installé sur des sites : la nouvelle table va alors dans les deux, CREATE TABLE IF NOT EXISTS dans la migration.
  • Une mise à jour par le marketplace ou par une archive rejoue install.sql avant les migrations : un site qui saute des versions reçoit les tables de la dernière, puis joue les anciennes migrations sur elles. Une migration qui écrit des données vise donc le schéma du dernier install.sql, et se réécrit quand il change — un site qui l'a déjà jouée la reconnaît à son nom et ne la rejoue pas. Pour la même raison, ne renomme jamais une migration.
  • Sur le site de démonstration, une mise à jour d'addon lancée depuis l'administration ne joue rien (Addon::migrate() sort quand nf_demo() est vrai) ; les migrations arrivées avec un code neuf s'y appliquent comme ailleurs.

Le dossier migrations/ à la racine du projet est réservé aux évolutions transverses du cœur.

Dépendre d'un autre module sans casser

Le paquet s'installe à la carte : le module dont tu as besoin peut ne pas être là. Quatre façons de dépendre, et ce qui se passe s'il manque (tools/check-addon-coupling.php les relève au tokenizer) :

Moyen Si l'addon manque À faire
lire sa table (from('nf_teams')) fatal — « Table doesn't exist » le déclarer dans requires, ou garder par $this->db->table_exists('nf_teams') et annoter
nommer sa classe fatal — « Class not found » idem
appeler son service ($this->module('teams'), model2('team')) tolérant : rend NULL tester le retour : if ($teams = $this->module('teams'))
écrire son URL (url('teams/…')) cosmétique : lien mort acceptable

Un couplage fatal ni déclaré ni annoté fait échouer la CI. L'annotation se pose à l'endroit exact, en commentaire : // couplage(teams): purge à la suppression d'un jeu — gardé par table_exists(), ou en tête de fichier couplage(teams): … quand les références y sont étalées.

Types de contenu : réactions, abonnements, révisions, corbeille

Si ton module publie du contenu (comme les actualités, les articles, les sujets), déclare-le : les modules transverses — réactions, notifications, révisions, corbeille, gamification — le collectent au lieu de porter chacun leur propre liste de tables.

public function declare_content_types()
{
    return [
        'note' => [
            'table' => 'nf_notes', 'pk' => 'id', 'author' => 'user_id',
            'reactable' => TRUE, 'subscribable' => TRUE, 'revisable' => FALSE,
        ],
    ];
}

Exemple réel : modules/news/news.php.

Le référencement d'un contenu

Un type de contenu déclaré ci-dessus (avec sa table et sa pk) peut recevoir, comme les actualités ou le wiki, un titre et une description pour les moteurs — la page commune est celle de la rubrique Référencement, rien n'est à écrire de ce côté. Trois appels :

// La carte d'édition de l'administration : le bouton qui mène à la page commune.
return $this->admin_card('fas fa-edit', $titre, $formulaire, '', nf_seo_bouton('note', (int) $id));

// L'action de la page publique, avant de rendre la vue : applique le titre et la description saisis.
nf_seo_contenu('note', (int) $id);

// Quand l'adresse d'un contenu change (un nom, un slug), l'ancienne mène à la nouvelle (301).
nf_redirection_ajouter('notes/'.$ancien_slug, 'notes/'.$nouveau_slug);

Exemples réels : modules/wiki/controllers/admin.php et modules/wiki/controllers/index.php.

Les carrefours : statistiques, activité, tableau de bord, recherche, plan du site

Un module se branche sur une page qui agrège en posant un contrôleur du nom du carrefour. Le carrefour appelle la méthode du même nom sans rien vérifier : renommée, rendue privée ou dotée d'un paramètre obligatoire de plus, l'erreur n'apparaît qu'à l'ouverture de la page — c'est pourquoi tools/check-addon-contracts.php fige ces contrats en CI.

Contrôleur Méthode attendue Appelé par
controllers/statistics.php statistics() la page Statistiques de l'administration
controllers/activity.php activity($user_id, $limit) le profil d'un membre
controllers/dashboard.php dashboard() le tableau de bord de l'administration
controllers/block.php block() les blocs [block:…] des pages
controllers/search.php search() et suggest() la recherche globale et la suggestion instantanée — un module qui n'a que search() est ignoré en silence
controllers/sitemap.php sitemap() le plan du site (/sitemap.xml, un par langue) : rend [['adresse' => 'monmodule/12/titre', 'date' => …], …], des chemins comme ceux que prend url(), seulement ce qu'un visiteur peut lire ($this->access('monmodule', 'lire', $id, 'visitors')) et ce qui existe dans la langue du plan — un contenu qui n'a pas de langue à lui (un sujet, un ticket, une page faite de ces contenus) porte 'sans_langue' => TRUE : il ne figure qu'au plan de la langue première du site, et sa page appelle nf_seo_sans_langue(), qui y place sa canonique (six langues d'un même texte, chacune canonique : Google en retient une autre et ne les indexe pas) ; une rubrique vide rend [] : sa page n'aurait que « rien pour l'instant » à montrer ; sans le carrefour, le module est absent des moteurs, et IndexNow ne signale pas ses pages : la tâche planifiée compare ce même plan d'un passage à l'autre

L'espace membre et le profil public

Trois méthodes de la classe du module, facultatives, et lues seulement si elles existent :

  • espace_membre($user) ajoute des pages au menu de l'espace membre — la colonne de gauche sur ordinateur, la bande d'onglets au téléphone, repris aussi dans la barre du haut des thèmes et le widget « Espace membre ». Elle rend une liste d'entrées : url, titre, icone, au besoin badge (un nombre à signaler) et compact (montrée aussi dans les menus courts), et ordre pour les ranger entre elles.
  • profil_membre($membre) ajoute des onglets au profil public d'un membre, chacun à son adresse user/<id>/<pseudo>/<onglet>. Elle rend une liste d'onglets : onglet (le segment d'adresse : lettres minuscules, chiffres et tirets), titre, icone, ordre, au besoin nombre (montré à côté du titre et dans « En chiffres »), et contenu, une fonction qui rend la page de l'onglet — appelée seulement quand on l'ouvre. Rien à montrer, rien à rendre : un onglet vide n'apparaît pas, et son adresse répond 404.
  • types_de_notification() déclare les notifications que le module envoie, pour les préférences de chaque membre (user/notifications/preferences) : type (celui que reçoit push()), titre (ce que le membre lit, « Une réponse dans un sujet que je suis »), email à TRUE si le module sait aussi l'envoyer par e-mail, et ordre. Un type envoyé doit être déclaré : sinon le membre ne peut pas le couper — tests/Unit/TypesDeNotificationTest.php lit les envois du dépôt et le refuse.
public function profil_membre($membre): array
{
    $notes = $this->db->from('nf_notes')->where('user_id', (int) $membre->id)->count();

    return $notes ? [[
        'onglet'  => 'notes',
        'titre'   => (string) $this->lang('Notes'),
        'icone'   => 'fas fa-note-sticky',
        'ordre'   => 50,
        'nombre'  => $notes,
        'contenu' => fn () => $this->view('profil-membre', ['notes' => $this->model()->du_membre((int) $membre->id)]),
    ]] : [];
}

Le profil montre ce que celui qui regarde peut lire : un onglet qui liste des contenus filtre comme ses propres pages ($this->access(…)) — le forum n'y montre que les catégories lisibles. Ce que le membre choisit de cacher de lui (points, karma, VIP, âge, présence en ligne) se demande à son modèle : $membre->montre('statut'). Exemples réels : modules/forum/forum.php, modules/teams/teams.php.

Une notification part par push($user_id, $type, $titre, $adresse, $acteur) du module notifications (ou push_unique(), qui n'en ajoute pas une seconde du même type à la même adresse tant que la première n'est pas lue) : ce que le membre a coupé sur le site n'est pas écrit. Un module qui l'envoie aussi par e-mail pose d'abord la question lui-même, $notifications->veut($user_id, $type, 'email'), et n'écrit pas s'il répond non. Depuis la classe du module, charge-le par \NF\NeoFrag\Addons\Module::__load(\NeoFrag(), ['notifications']) — $this->module() s'y trompe de type d'addon. Exemple réel : modules/talks/talks.php.

6. Les permissions (optionnel)

public function permissions()
{
    return [
        'default' => [
            'access' => [
                [
                    'title'  => $this->lang('Notes'),
                    'icon'   => 'fas fa-note-sticky',
                    'access' => [
                        'add_note'    => ['title' => $this->lang('Ajouter'),   'icon' => 'fas fa-plus', 'admin' => TRUE],
                        'delete_note' => ['title' => $this->lang('Supprimer'), 'icon' => 'far fa-trash-alt', 'admin' => TRUE],
                    ],
                ],
            ],
        ],
    ];
}

Les permissions deviennent éditables dans Administration → Permissions (matrice rôle × action). Dans le code : $this->access('notes', 'add_note'). Exemple réel : modules/news/news.php.

Une permission par élément (une catégorie, une galerie) déclare ses valeurs de départ dans init, que $this->access->init('notes', 'categorie', $id) pose à la création de l'élément. Le rôle « membre » n'héritant pas du rôle « visiteur », init suit les règles d'origine de NeoFrag quand members n'est pas nommé : une liste vide rend l'action publique (visiteurs et membres) ; ['visitors', TRUE] vaut aussi pour les membres ; ['visitors', FALSE] ne refuse que les visiteurs — les membres gardent l'action ; et ['admins', TRUE] la réserve aux administrateurs. Exemple réel : modules/forum/forum.php (lire, écrire, modérer une catégorie).

7. L'administration (optionnel)

Si 'admin' => TRUE, ajoute controllers/admin.php (classe Admin extends Controller_Module). Sa méthode index() est servie sous /admin/notes.

<?php
namespace NF\Modules\Notes\Controllers;

use NF\NeoFrag\Loadables\Controllers\Module as Controller_Module;

class Admin extends Controller_Module
{
    public function index()
    {
        $this->title($this->lang('Notes'))->icon('fas fa-note-sticky');

        $this->form()
             ->add_rules([
                 'title' => ['label' => $this->lang('Titre'),   'type' => 'text',   'rules' => 'required'],
                 'body'  => ['label' => $this->lang('Contenu'), 'type' => 'editor'],
             ])
             ->add_submit($this->lang('Ajouter'));

        if ($this->form()->is_valid($post))
        {
            $this->db->insert('nf_notes', ['title' => $post['title'], 'body' => $post['body']]);
            notify($this->lang('Note ajoutée.'));
            redirect('admin/notes');
        }

        return $this->admin_card('fas fa-note-sticky', $this->lang('Notes'), $this->form()->display());
    }
}

C'est form() (add_rules() / is_valid() / display()) qui est l'API de formulaire des écrans d'administration ; form2() (cf. Le framework) sert aux formulaires publics, riches ou de confirmation seule. Le trait Admin_Helpers, disponible sur tout contrôleur de module, habille le contenu : admin_card(), admin_create(), admin_back(), admin_split(), admin_action_bar(), admin_empty(), admin_stats(), sort_select(). Toute sous-page doit offrir un retour (admin_back() ou le fil d'Ariane) : tools/check-admin-back.php le vérifie sur chaque page d'administration.

Actions mutantes : exiger un jeton CSRF

Toute action qui modifie quelque chose déclenchée par un lien GET ou un POST écrit à la main (hors form() / form2(), qui portent leur propre jeton) doit être protégée : SameSite=Lax ne suffit pas.

// Génération du lien : jeton en query.
$html .= '<a href="'.$this->csrf_url('admin/notes/delete/'.$id).'">'.icon('far fa-trash-alt').'</a>';

// Contrôleur : vérifie le jeton AVANT de muter, sinon redirige.
public function _delete($note)
{
    $this->check_csrf('admin/notes');
    $this->db->where('id', $note['id'])->delete('nf_notes');
    notify($this->lang('Note supprimée.'));
    redirect('admin/notes');
}

Pour un POST manuel, le jeton va en champ caché : <input type="hidden" name="_" value="<?php echo $this->csrf_token() ?>">.

Tables d'administration

table2() rend des listes paginées, triables par clic sur l'en-tête et filtrables (exemple : modules/user/controllers/admin.php). Le tri est géré par js/table2.js, en vanilla. Une liste que le checker découpe à la main (->paginate()) rend ses liens de pages : le contrat est dans Le framework, et check-pagination le vérifie.

La charte de l'administration

L'administration est sobre : le fond s'efface derrière les données, la couleur d'accent est rare, et les couleurs vives sont réservées à ce qui appelle une action ou signale un état. Une page de module se compose ainsi :

  • Une carte par liste : admin_card(icône, titre, corps, sous-titre, actions) — le sous-titre porte le compte (« 3 publiées · 1 brouillon »), les actions le bouton qui crée.
  • Le bouton qui crée (« Nouvelle citation ») : admin_create(url, libellé), un btn btn-primary btn-sm dans l'en-tête de la carte de la liste qu'il alimente. La barre du haut garde les outils de la page — Permissions, Configuration, Aide.
  • Les actions d'une ligne : des boutons à icône seule, petits. Modifier (crayon), accès (cadenas), trier : btn-outline-secondary, neutres. Supprimer : btn-outline-danger avec l'icône far fa-trash-alt. Jamais de bouton plein dans une ligne. button_update(), button_access() et button_delete() les rendent ainsi ; un bouton écrit à la main suit la même règle, que tools/check-actions-admin.php vérifie.
  • Les autres actions d'une ligne (aperçu, dupliquer, restaurer) : un contour, jamais un bouton plein ; une teinte seulement si elle porte un sens (btn-outline-warning pour un aperçu qui change le mode de navigation, btn-outline-success pour restaurer).
  • Les pastilles d'état : badge text-bg-success (publié, actif), text-bg-secondary (brouillon, inactif), text-bg-warning (en attente), text-bg-danger (erreur) — le thème d'administration les rend douces (fond pâle, texte appuyé), en clair comme en sombre.
  • Un tableau pour des données en colonnes (titre, compte, statut) ; des cartes (nf-content-card) pour des contenus rédigés qu'on reconnaît à leur extrait. Une liste qui se range par famille (Templates emails) reste un seul tableau, une ligne <tr class="nf-table-groupe"> en tête de chaque famille, plutôt qu'une carte par famille. Une date de la base s'y affiche avec nf_date_heure(), jamais telle quelle.
  • L'état vide : admin_empty(icône, titre, texte), qui dit quoi faire pour commencer.
  • Le bouton qui envoie un formulaire : « Enregistrer » pour une fiche qui existe — jamais « Éditer », on est déjà en train de la modifier —, « Ajouter » ou « Créer » pour une nouvelle, comme le titre de la page (« Ajouter un forum »).
  • Plusieurs vues sur un même sujet (Monitoring, Utilisateurs, Discord) : des onglets nf-local-nav / nf-local-tab — en liens vers d'autres adresses, ou en boutons qui basculent des panneaux.

8. Éprouver le module

Le projet a un filet, et un module neuf doit y entrer :

  • Tests — tests/Unit (sans base), tests/Headless (le framework booté, de vrais modèles contre la base de test, chaque test dans une transaction annulée — HeadlessTestCase), tests/Integration (miroir SQL), tests/Browser/*.test.html (contrat d'un script dans un vrai navigateur, via tools/check-js.php). vendor/bin/phpunit --fail-on-skipped.
  • Contrôles à faire passer avant de livrer : check-addon-declarations, check-addon-coupling, check-addon-contracts, check-js-sources, check-langs --toutes, check-textes-en-dur (aucun texte visible hors de lang()), check-actions-admin (la charte des boutons), check-pagination, check-heures (aucune date affichée sans fuseau), check-db-compteurs, check-demo-lock (un module qui touche à la configuration est verrouillé en démonstration), check-vignettes (sa vignette existe, au format), check-strict-types (le compteur ne doit jamais baisser — déclare declare(strict_types=1) dans tes fichiers neufs), puis check-install-profiles (installe chaque profil pour de vrai et frappe les routes des modules absents, qui doivent rendre un 404 propre, jamais un 500) et check-js-console (ouvre les pages d'administration dans un navigateur et refuse toute erreur JS).
  • PHPStan : vendor/bin/phpstan analyse.

La liste complète est dans tools/README.md ; php tools/check-all.php --navigateur les joue tous.

9. Installer et distribuer

  1. Dépose modules/notes/.
  2. Administration → Thèmes & Addons → Scanner le disque → coche notes → installe (l'installation joue install/install.sql, baseline les migrations, pose les permissions).
  3. Pour le distribuer : zippe le dossier (modules/notes/ à la racine de l'archive), il s'installe via Ajouter (ZIP). Pour le publier au catalogue du marketplace du projet, il doit avoir 'core' => FALSE et pas de 'distributed' => FALSE ; tools/package-addons.php zippe et inscrit tous les addons optionnels dans marketplace/catalog.json avec leur empreinte SHA-256.
  4. Sa vignette, avant le packaging : une carte dessinée à partir de son __info() — son icône, son nom et sa description en français —, lisible à toute taille, même quand le module n'a rien à montrer : php tools/vignettes-cartes.php --nom=notes --sortie=/tmp/cartes pour la regarder, puis --poser pour la mettre en place. Format et contrôles : Créer un thème, § 5.

Gérer mes cookies

Le site ne dépose que les cookies nécessaires à son fonctionnement : ils ne demandent pas votre accord. Les autres services appartiennent à d’autres sociétés ; ils ne se chargent que si vous les acceptez. Votre choix est gardé six mois, et vous pouvez le changer à tout moment.

Nécessaires au site Toujours actifs

  • __Host-session Garde votre connexion et protège les formulaires contre les envois frauduleux. Jusqu’à la fermeture du navigateur ; un an avec « Se souvenir de moi »
  • nf_consent Garde les choix que vous faites dans cette fenêtre. 6 mois
  • nf_fuseau Affiche les dates et les heures dans votre fuseau horaire. 1 an
  • Stockage du navigateur Garde votre choix entre le mode jour et le mode nuit, et la présentation choisie pour la liste des articles. Jusqu’à ce que vous le vidiez

Vidéos

Les vidéos intégrées aux pages : le lecteur vient de la plateforme, qui reçoit votre adresse IP et peut déposer ses cookies.

Musique

Les morceaux et listes de lecture intégrés aux pages : le lecteur vient de la plateforme, qui reçoit votre adresse IP et peut déposer ses cookies.