@nodefony/documentation 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +168 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/index.js +75 -0
  7. package/dist/nodefony/config/config.js +71 -0
  8. package/dist/nodefony/config/defineModuleConfig.js +49 -0
  9. package/dist/nodefony/controller/DocumentationController.js +102 -0
  10. package/dist/nodefony/interfaces/IDocumentation.js +1 -0
  11. package/dist/nodefony/interfaces/index.js +1 -0
  12. package/dist/nodefony/service/DocumentationService.js +421 -0
  13. package/dist/nodefony/src/docScanner.js +58 -0
  14. package/dist/nodefony/src/errors/DocumentationError.js +45 -0
  15. package/dist/nodefony/src/frontmatter.js +63 -0
  16. package/dist/nodefony/src/linkResolver.js +68 -0
  17. package/dist/nodefony/src/search.js +120 -0
  18. package/dist/nodefony/src/slug.js +62 -0
  19. package/dist/types/index.d.ts +58 -0
  20. package/dist/types/nodefony/config/config.d.ts +37 -0
  21. package/dist/types/nodefony/config/defineModuleConfig.d.ts +17 -0
  22. package/dist/types/nodefony/controller/DocumentationController.d.ts +33 -0
  23. package/dist/types/nodefony/interfaces/IDocumentation.d.ts +144 -0
  24. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  25. package/dist/types/nodefony/service/DocumentationService.d.ts +78 -0
  26. package/dist/types/nodefony/src/docScanner.d.ts +47 -0
  27. package/dist/types/nodefony/src/errors/DocumentationError.d.ts +32 -0
  28. package/dist/types/nodefony/src/frontmatter.d.ts +37 -0
  29. package/dist/types/nodefony/src/linkResolver.d.ts +55 -0
  30. package/dist/types/nodefony/src/search.d.ts +64 -0
  31. package/dist/types/nodefony/src/slug.d.ts +48 -0
  32. package/docs/architecture.md +647 -0
  33. package/docs/index.md +369 -0
  34. package/package.json +81 -0
@@ -0,0 +1,647 @@
1
+ ---
2
+ title: "Architecture — du fichier .md au portail navigable"
3
+ navTitle: Architecture
4
+ lang: fr
5
+ module: "@nodefony/documentation"
6
+ topic: documentation
7
+ coverageModule: documentation
8
+ section: "Documentation"
9
+ audience: [developer]
10
+ tags:
11
+ [
12
+ documentation,
13
+ architecture,
14
+ scan,
15
+ frontmatter,
16
+ slug,
17
+ liens,
18
+ allowlist,
19
+ data-plane,
20
+ portail,
21
+ ]
22
+ version: "doc"
23
+ status: stable
24
+ updated: 2026-07-19
25
+ source: "src/packages/@nodefony/documentation/docs/architecture.md"
26
+ ---
27
+
28
+ # Architecture — du fichier `.md` au portail navigable
29
+
30
+ > Tu écris un fichier Markdown à côté de ton code. Quelques secondes plus tard, il est
31
+ > dans le portail, rangé dans la bonne section, avec ses liens qui marchent et un bouton
32
+ > « voir la source ». Cette page décrit la machinerie entre les deux : ce que le module va
33
+ > chercher sur le disque, ce qu'il lit dans ton frontmatter, comment il fabrique une **cote**
34
+ > à partir d'un chemin, et pourquoi c'est **le serveur** — pas ton navigateur — qui traduit
35
+ > tes liens relatifs. Tout est ancré sur
36
+ > `src/packages/@nodefony/documentation/nodefony/`.
37
+
38
+ 📍 [Documentation](../../../../../docs/index.md) › [Documentation (module)](index.md) › **Architecture**
39
+
40
+ ## 🧠 Le modèle mental — un bibliothécaire, un catalogue, une cote
41
+
42
+ Les livres sont dispersés : certains dans la salle commune (`docs/` à la racine du projet),
43
+ la plupart rangés à côté de l'atelier qui les a écrits (`<module>/docs/`). Trois objets
44
+ suffisent à comprendre l'ensemble :
45
+
46
+ - Le **bibliothécaire**, c'est le service. Il fait le tour des étagères une fois, retient
47
+ où chaque livre se trouve **réellement**, et garde ce tour de piste en mémoire.
48
+ - Le **catalogue**, c'est l'index. Il ne contient pas les livres, seulement de quoi les
49
+ choisir : titre, section, persona, statut.
50
+ - La **cote**, c'est le _slug_. Tu la demandes, on va chercher le livre. Tu ne peux pas
51
+ fabriquer une cote pour un livre qui n'est pas au catalogue — c'est ce qui rend le
52
+ rayonnage inviolable.
53
+
54
+ ```mermaid
55
+ flowchart TD
56
+ subgraph DISQUE["Le disque — la doc vit à côté du code"]
57
+ R["docs/ (racine)<br/>guides · ADR · architecture"]
58
+ M["&lt;module&gt;/docs/*.md<br/>ADR-0001"]
59
+ N["node_modules/@nodefony/*/docs<br/>paquets installés, même non chargés"]
60
+ end
61
+ R --> SCAN["scanDocsDir()<br/>best-effort, récursif"]
62
+ M --> SCAN
63
+ N --> SCAN
64
+ SCAN --> FM["parseFrontmatter()<br/>YAML plat, 0 dépendance"]
65
+ FM --> IDX["Index en cache<br/>slug → ScannedDoc · chemin repo → slug"]
66
+ IDX --> TREE["GET /api/tree<br/>sections ordonnées, hub en tête"]
67
+ IDX --> PAGE["GET /api/page/{slug}"]
68
+ PAGE --> RES["variables {{ }} résolues<br/>+ liens relatifs traduits en slugs"]
69
+ RES --> UI["Portail Studio · site statique · RAG"]
70
+ ```
71
+
72
+ Trois règles tiennent tout l'édifice :
73
+
74
+ 1. **Le slug est une clé, jamais un chemin.** On ne reconstruit jamais un chemin de fichier
75
+ à partir de ce que le client envoie.
76
+ 2. **Le cache porte sur l'index, pas sur le contenu.** Une page est **toujours** relue sur
77
+ le disque — on ne sert jamais un Markdown périmé.
78
+ 3. **La traduction des liens appartient au serveur.** Lui seul connaît la table
79
+ chemin → slug ; le client n'a aucun moyen de deviner à quoi correspond `../../..`.
80
+
81
+ ## 📖 Lexique
82
+
83
+ | Terme | Sens |
84
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
85
+ | Data plane | Le plan de **données** : des routes qui rendent du JSON structuré, sans rien afficher. Par opposition au plan de présentation (l'écran). |
86
+ | Headless | « Sans tête » : le module produit de la donnée, jamais du HTML. Le rendu appartient au consommateur. |
87
+ | Frontmatter | Le bloc encadré de `---` en tête d'un `.md`, qui porte les métadonnées de la page (titre, persona, statut, date). |
88
+ | Slug | La **cote** d'une page : un identifiant URL-safe tenant sur un seul segment de route (`mod~security~cors`). |
89
+ | Allowlist | Liste blanche : seules les entrées connues du scan sont servables. Tout le reste est refusé, sans discussion. |
90
+ | Traversée de répertoire | _Path traversal_ : faire lire au serveur un fichier hors du périmètre prévu, en glissant des `..` dans un identifiant. |
91
+ | Hub | La page d'entrée d'une section (`index.md`) : elle oriente au lieu d'expliquer. |
92
+ | ADR | _Architecture Decision Record_ : une décision d'architecture écrite, datée et motivée. |
93
+ | ADR-0001 | La décision d'**emplacement hybride** : la doc d'un module vit DANS le module, le transverse reste à la racine. |
94
+ | TTL | _Time To Live_ : durée de fraîcheur. Ici, celle de l'index — passé le délai, le disque est re-parcouru. |
95
+ | Real-path | Le chemin réel d'un fichier, liens symboliques résolus. En dépôt workspace, `node_modules/@nodefony/x` mène à la source. |
96
+ | POSIX (chemin) | Forme de chemin à séparateur `/`. Le module normalise tout dessus, y compris ce qui arrive de Windows. |
97
+ | YAML plat | Le sous-ensemble de YAML accepté ici : `clé: valeur`, listes inline `[a, b]`, listes en bloc. Ni objet imbriqué, ni multi-lignes. |
98
+ | Fence typée | Un bloc de code dont le langage déclare un composant (` ```nodefony-cards `) plutôt qu'un langage de programmation. |
99
+ | RBAC | _Role-Based Access Control_ : qui a le droit, décidé par les rôles portés par l'identité. |
100
+ | RAG | _Retrieval-Augmented Generation_ : donner à un modèle les documents pertinents avant qu'il réponde. Le Markdown en est la matière. |
101
+ | Cliquet (test) | Un garde-fou qui n'autorise qu'un sens : une liste de dette connue qui ne peut que **rétrécir**, jamais s'allonger. |
102
+
103
+ ## Qu'est-ce qu'un data plane de documentation ?
104
+
105
+ Le réflexe habituel, pour publier de la doc, c'est un **générateur de site statique** : on
106
+ compile le Markdown en HTML, on déploie le résultat. Ça marche — tant que la doc et le code
107
+ ne bougent pas ensemble.
108
+
109
+ Nodefony prend l'autre bout du problème : la doc est **servie par l'application elle-même**,
110
+ en direct, depuis les fichiers du dépôt. Le module ne compile rien, ne rend rien, ne cache
111
+ aucun contenu. Il répond à deux questions, et à deux seulement :
112
+
113
+ 1. **Qu'est-ce qu'il y a à lire ?** → l'index, avec ses sections et ses pages.
114
+ 2. **Donne-moi cette page-là.** → le Markdown, métadonnées à part, prêt à afficher.
115
+
116
+ C'est ce que veut dire **headless** (`Documentation` — `index.ts:31`) : la sortie est du JSON
117
+ (`IDocPage`, `IDocumentation.ts:67`), et trois consommateurs très différents s'en servent —
118
+ le portail Studio (React), un futur générateur de site, et l'indexation RAG qui réingère le
119
+ Markdown brut.
120
+
121
+ > [!TIP]
122
+ > C'est aussi ce qui rend ta doc **vraie**. Un site statique se régénère quand quelqu'un y
123
+ > pense ; ici, la page servie est le fichier tel qu'il est sur le disque, à l'instant de la
124
+ > requête.
125
+
126
+ ## La vision Nodefony — la doc vit à côté du code, l'index la rassemble
127
+
128
+ Quatre partis pris expliquent la forme du module.
129
+
130
+ **La doc appartient au module** (ADR-0001). Tu écris `mon-module/docs/ma-page.md`, tu ne
131
+ déclares rien, tu n'inscris rien nulle part : le scan la trouve au prochain passage. La
132
+ contrepartie, c'est qu'il faut un **index transverse** pour recoller des dizaines de dossiers
133
+ séparés — c'est précisément le travail de ce module.
134
+
135
+ **Les briques de base sont pures.** Le découpage du frontmatter (`parseFrontmatter()`,
136
+ `frontmatter.ts:51`), la fabrication du slug (`pathToSlug()`, `slug.ts:60`), le parcours du
137
+ disque (`scanDocsDir()`, `docScanner.ts:55`) et la traduction des liens
138
+ (`rewriteInternalLinks()`, `linkResolver.ts:90`) sont des fonctions sans état et sans Kernel.
139
+ Elles sont exportées telles quelles, donc réutilisables par un générateur statique ou un
140
+ indexeur RAG — et testables sans démarrer un serveur.
141
+
142
+ **Le slug est une clé d'allowlist, jamais un chemin.** Le module lit des fichiers du disque
143
+ sur ordre d'un client : c'est la définition d'une surface de traversée de répertoire. La
144
+ parade n'est pas un filtre de caractères, c'est un **changement de nature** — le détail est
145
+ plus bas.
146
+
147
+ **Ce que tu écris reste lisible sur GitHub.** Tes liens sont des chemins relatifs — un lien
148
+ markdown dont la cible est `cors.md` — et tes ancres suivent la convention GitHub. Le portail
149
+ s'adapte à ton Markdown, pas l'inverse.
150
+
151
+ **Le compromis, dit franchement** : l'index est un instantané. Un `.md` ajouté n'apparaît
152
+ qu'au prochain scan — 30 secondes par défaut, immédiatement si tu mets le cache à zéro.
153
+
154
+ ## 🚀 Démarrage rapide
155
+
156
+ Le but : publier la doc d'une application créée par `nodefony create app`, et y injecter une
157
+ valeur calculée par le serveur.
158
+
159
+ ### 1. Charger le module
160
+
161
+ ```ts
162
+ // nodefony.config.ts — l'orchestrateur de l'application
163
+ import { defineConfig, use } from "nodefony";
164
+
165
+ export default defineConfig(() => ({
166
+ modules: [
167
+ "@nodefony/framework",
168
+ use("@nodefony/documentation", {
169
+ // La doc transverse de l'app. Les `<module>/docs/` s'ajoutent tout seuls.
170
+ scan: { rootDir: "docs" },
171
+ // Le lien « voir la source » de chaque page pointe vers TON dépôt.
172
+ repo: { url: "https://github.com/acme/monapp", editPathPrefix: "blob" },
173
+ // 0 = pas de cache : un nouveau `.md` apparaît à la requête suivante.
174
+ cache: { ttlMs: 0 },
175
+ }),
176
+ ],
177
+ }));
178
+ ```
179
+
180
+ ### 2. Écrire une page
181
+
182
+ Le fichier `docs/prise-en-main.md` de ton application, avec son frontmatter :
183
+
184
+ ```markdown
185
+ ---
186
+ title: "Prise en main"
187
+ audience: [developer]
188
+ status: stable
189
+ updated: 2026-07-19
190
+ ---
191
+
192
+ # Prise en main
193
+
194
+ Besoin d'aide ? Écris à {{ support }}.
195
+
196
+ Suite : [le sommaire](index.md).
197
+ ```
198
+
199
+ Deux détails qui font tout le reste : `{{ support }}` sera remplacé **côté serveur**, et le
200
+ lien relatif sera traduit en slug navigable — sans cesser de fonctionner sur GitHub.
201
+
202
+ ### 3. Fournir la variable `{{ support }}`
203
+
204
+ ```ts
205
+ // nodefony/modules/glossaire/index.ts — un module de ton application
206
+ import { Kernel, Module } from "nodefony";
207
+ import type { IDocumentationService } from "@nodefony/documentation";
208
+
209
+ class Glossaire extends Module {
210
+ constructor(kernel: Kernel) {
211
+ super("glossaire", kernel, import.meta.url, {});
212
+ }
213
+
214
+ // `onKernelReady` : tous les modules sont bootés, le service existe.
215
+ override async onKernelReady(): Promise<this> {
216
+ const docs = this.get<IDocumentationService>("documentation");
217
+ // Valeur SÛRE et synchrone : jamais un secret, jamais un chemin absolu.
218
+ docs?.registerVar("support", () => "support@acme.example");
219
+ return this;
220
+ }
221
+ }
222
+
223
+ export default Glossaire;
224
+ ```
225
+
226
+ ### Ce qu'on observe
227
+
228
+ L'index annonce la page, rangée dans la section de son dossier :
229
+
230
+ ```bash
231
+ curl -s http://127.0.0.1:5151/nodefony/documentation/api/tree | head -20
232
+ # {
233
+ # "generatedAt": "2026-07-19T10:12:03.114Z",
234
+ # "audiences": [{ "key": "developer", "label": "Développeur", "desc": "…" }, …],
235
+ # "sections": [
236
+ # { "id": "root-racine", "label": "docs/ (racine)",
237
+ # "pages": [{ "slug": "root~prise-en-main", "title": "Prise en main",
238
+ # "audience": ["developer"], "isHub": false, "status": "stable" }] }
239
+ # ]
240
+ # }
241
+ ```
242
+
243
+ Puis la page elle-même, variable résolue et lien traduit :
244
+
245
+ ```bash
246
+ curl -s http://127.0.0.1:5151/nodefony/documentation/api/page/root~prise-en-main
247
+ # {
248
+ # "slug": "root~prise-en-main", "title": "Prise en main",
249
+ # "version": "doc", "status": "stable", "updated": "2026-07-19",
250
+ # "source": "docs/prise-en-main.md",
251
+ # "sourceUrl": "https://github.com/acme/monapp/blob/main/docs/prise-en-main.md",
252
+ # "markdown": "\n# Prise en main\n\nBesoin d'aide ? Écris à support@acme.example.\n…"
253
+ # }
254
+ ```
255
+
256
+ Le `markdown` rendu ne porte plus ni frontmatter, ni `{{ }}`, ni chemin relatif : la cible
257
+ `index.md` du lien y est devenue `root~index.md`. Trois transformations, une seule lecture de
258
+ fichier.
259
+
260
+ ## 🏗️ Architecture interne — trois couches
261
+
262
+ Chaque couche ne connaît que sa voisine du dessous, et la plus volatile est la plus mince.
263
+
264
+ | Couche | Qui | Sa seule responsabilité | Ce qu'elle ignore |
265
+ | ------------- | ------------------------------------------------------ | ---------------------------------------------------- | ------------------------------------ |
266
+ | Contrôleur | `DocumentationController` — sans état | traduire un résultat (ou une erreur) en réponse HTTP | comment l'index est construit |
267
+ | Service | `DocumentationService` — le seul stateful | scanner, cacher, indexer, résoudre | qui l'appelle, et par quel transport |
268
+ | Briques pures | `frontmatter` · `slug` · `docScanner` · `linkResolver` | une transformation, sans état ni Kernel | qu'un serveur existe |
269
+
270
+ Le contrôleur est **réinstancié à chaque requête** : il ne peut donc rien retenir, et c'est
271
+ voulu. Le service est un singleton par process ; il porte l'index caché (`#cache`,
272
+ `DocumentationService.ts:138`) et le registre des variables (`#vars`,
273
+ `DocumentationService.ts:140`), tous deux à `null` tant que personne n'a rien demandé.
274
+
275
+ ### Le scan — trois sources, et une qui surprend
276
+
277
+ `#scanAll()` (`DocumentationService.ts:324`) interroge le disque dans cet ordre :
278
+
279
+ | Source | Où | Pourquoi |
280
+ | ------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------- |
281
+ | La doc transverse | `docs/` à la racine du projet | guides, ADR, architecture — ce qui n'appartient à aucun module |
282
+ | Les modules **chargés** | `<module>/docs/` de chaque module du manifeste | ADR-0001 : la doc vit à côté du code qu'elle décrit |
283
+ | Les paquets **installés** | `node_modules/@nodefony/*/docs` + `nodefony` | lire la doc d'un module **avant** de l'activer — c'est justement à ce moment-là |
284
+
285
+ La troisième mérite l'explication. Un module qu'on n'a pas encore activé est précisément
286
+ celui dont on lit la doc : pour décider de l'activer. `#installedDocDirs()`
287
+ (`DocumentationService.ts:386`) parcourt donc le scope npm et **dédoublonne** avec les
288
+ modules déjà chargés. Ses chemins sont résolus en real-path : en dépôt workspace,
289
+ `node_modules/@nodefony/x` est un lien vers la source, et c'est la source qui doit indexer —
290
+ sinon un même fichier aurait deux chemins, et les liens entre pages ne se résoudraient plus.
291
+
292
+ Le parcours lui-même, `scanDocsDir()` (`docScanner.ts:55`), est **best-effort** par
293
+ construction : un dossier absent rend une liste vide au lieu de lever une erreur. C'est ce
294
+ qui permet de balayer les `docs/` de modules qui n'en ont pas, sans que rien ne plante. Un
295
+ fichier illisible garde un titre dérivé de son nom (`humanizeFilename()`, `docScanner.ts:29`)
296
+ et un frontmatter vide.
297
+
298
+ L'exclusion (`isExcluded()`, `docScanner.ts:38`) compare **par segment de chemin**, pas par
299
+ préfixe : `node_modules` exclut le dossier, jamais un fichier nommé `node_modules-guide.md`.
300
+
301
+ ### Le frontmatter — ce que le module lit vraiment
302
+
303
+ `parseFrontmatter()` (`frontmatter.ts:51`) est un parseur maison de **YAML plat**. Pas de
304
+ `gray-matter` : la doc n'utilise qu'un sous-ensemble minuscule, et on ne paie pas des
305
+ dépendances transitives pour ça.
306
+
307
+ | Tu écris | Tu obtiens |
308
+ | ---------------------- | ---------------------- |
309
+ | `title: Mon titre` | une chaîne |
310
+ | `title: "Mon titre"` | idem (guillemets ôtés) |
311
+ | `audience: [a, b]` | une liste |
312
+ | `audience:` puis `- a` | une liste |
313
+ | `audience:` (seul) | une liste **vide** |
314
+ | `# commentaire` | ignoré |
315
+
316
+ **Non supporté, volontairement** : objets imbriqués, multi-lignes `|` / `>`, ancres YAML.
317
+ Une ligne mal formée est simplement sautée — elle ne fait jamais échouer la page.
318
+
319
+ Le service ne consomme ensuite qu'une poignée de clés (`getPage()`,
320
+ `DocumentationService.ts:151`) : `title`, `version` (défaut `"doc"`), `status`, `updated`,
321
+ `source`, plus `audience` pour l'index. **Toutes les autres clés sont conservées dans le
322
+ fichier et ignorées** — elles servent au RAG et aux outils, pas au portail.
323
+
324
+ Deux valeurs sont **contraintes**, et le hors-piste est silencieusement écarté :
325
+
326
+ | Clé | Valeurs retenues | Sinon |
327
+ | ---------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
328
+ | `audience` | `developer` · `devops` · `supervisor` · `admin` (`DocAudience`, `IDocumentation.ts:10`) | la valeur est filtrée (`#toPageRef()`, `DocumentationService.ts:511`) |
329
+ | `status` | `stable` · `draft` · `temporary` · `experimental` · `deprecated` (`DocStatus`, `IDocumentation.ts:13`) | le champ devient absent (`#coerceStatus()`, `DocumentationService.ts:532`) |
330
+
331
+ > [!WARNING]
332
+ > Une `audience: [human, ai]` ne provoque **aucune erreur** : les deux valeurs sont
333
+ > écartées, la page se retrouve avec une liste vide — c'est-à-dire « visible par toutes les
334
+ > personas ». L'inverse de ce que l'auteur croyait écrire. Le vocabulaire exact est celui de
335
+ > `AUDIENCES` (`DocumentationService.ts:37`), qui porte aussi les libellés affichés par le
336
+ > sélecteur de vue.
337
+
338
+ Et un point à ne pas confondre : **l'audience n'est pas un contrôle d'accès**. Elle n'existe
339
+ que dans l'index, comme filtre de vue ; `getPage()` sert n'importe quelle page indexée quel
340
+ que soit le persona du lecteur. Le vrai garde est le RBAC posé sur les routes.
341
+
342
+ ### Le slug — une cote, jamais un chemin
343
+
344
+ `pathToSlug()` (`slug.ts:60`) transforme un chemin en identifiant d'un seul segment :
345
+
346
+ | Fichier | Slug |
347
+ | --------------------------------- | ---------------------------- |
348
+ | `docs/index.md` | `root~index` |
349
+ | `docs/architecture/pipeline.md` | `root~architecture~pipeline` |
350
+ | `@nodefony/security/docs/cors.md` | `mod~security~cors` |
351
+
352
+ La recette : normaliser les `\` en `/`, retirer `.md`, remplacer chaque `/` par `~`, préfixer
353
+ par l'origine. Le nom du module perd son scope npm et tout caractère exotique
354
+ (`sanitizeSegment()`, `slug.ts:71`) — un module nommé n'importe comment produit quand même un
355
+ slug sûr.
356
+
357
+ Pourquoi `~` : le slug doit tenir dans **un** segment de route (`/api/page/{slug}`), donc il
358
+ ne peut pas contenir de `/`. Et la transformation est **à sens unique** : rien, nulle part,
359
+ ne reconstruit un chemin depuis un slug.
360
+
361
+ > [!IMPORTANT]
362
+ > Ne confonds pas deux slugs qui n'ont rien à voir. Celui-ci nomme une **page**
363
+ > (`mod~security~cors`). Les ancres de titre — celles de la forme `#pièges` — suivent une
364
+ > tout autre règle, celle de **GitHub** : accents **conservés**, ponctuation et emoji retirés, espaces en
365
+ > tirets — `slugifyHeading()` (`DocToc.tsx:54`). Retirer les accents côté portail rendait
366
+ > morts des liens qui marchaient sur GitHub. Toute divergence entre le portail et le gate
367
+ > `anchor-inpage` casse les sommaires **en silence**.
368
+
369
+ ### La résolution des liens — pourquoi c'est le serveur qui traduit
370
+
371
+ Tes pages se lient par **chemin relatif**, parce que c'est ce qui les rend lisibles partout :
372
+ sur GitHub, dans ton éditeur, dans une revue de diff. Mais le portail ne navigue pas par
373
+ chemin — il navigue par slug.
374
+
375
+ Le pont, c'est une table `chemin repo → slug` construite au scan (`#ensureCache()`,
376
+ `DocumentationService.ts:298`) et appliquée à la lecture par `#resolveLinks()`
377
+ (`DocumentationService.ts:282`). **Seul le serveur peut le faire** : le client reçoit
378
+ `../../../../../docs/index.md` sans le moindre moyen de savoir à quel fichier ça correspond —
379
+ il ne connaît ni l'arborescence du dépôt, ni le point de départ de la page.
380
+
381
+ `rewriteInternalLinks()` (`linkResolver.ts:90`) applique quatre règles :
382
+
383
+ 1. **Seules les cibles `.md` internes** sont touchées (`MD_LINK`, `linkResolver.ts:31`). Les
384
+ URL absolues, les `mailto:`, les ancres pures `#section`, les images et les `.ts` restent
385
+ intacts.
386
+ 2. **Le chemin est résolu contre le dossier de la page** (`resolveRelative()`,
387
+ `linkResolver.ts:43`), en saturant à la racine : une remontée excessive ne peut pas sortir
388
+ du dépôt.
389
+ 3. **Une cible non indexée reste telle quelle.** Mieux vaut un lien inerte qu'un slug inventé
390
+ qui produirait un 404.
391
+ 4. **Les fences typées aussi.** Un catalogue de hub porte ses cibles dans du JSON
392
+ (`"href": "cors.md"`) : sans traduction, `JSON_HREF` (`linkResolver.ts:40`), les cards
393
+ d'un hub renverraient dans le vide.
394
+
395
+ L'ancre de section est **préservée** : `pipeline.md#etapes` devient `root~…~pipeline.md#etapes`.
396
+ Et le `.md` est conservé après réécriture — c'est à cette extension que le rendu reconnaît un
397
+ lien interne.
398
+
399
+ ### L'ordre des pages — le hub ouvre sa section
400
+
401
+ Un tri purement alphabétique enterre `index.md` au milieu de ses propres pages : pour la
402
+ sécurité, entre `headers` et `lexique`. Le point d'entrée devient invisible.
403
+
404
+ `#orderPages()` (`DocumentationService.ts:486`) trie donc en deux temps : le hub d'abord, le
405
+ reste par titre. Un hub est reconnu à son nom de fichier — `index.md`, à n'importe quelle
406
+ profondeur — et le drapeau `isHub` (`IDocPageRef`, `IDocumentation.ts:24`) remonte jusqu'à
407
+ l'interface, où le portail s'en sert pour choisir la page d'atterrissage d'une section.
408
+
409
+ Les sections elles-mêmes (`#buildSections()`, `DocumentationService.ts:410`) viennent du
410
+ **dossier parent** du fichier, jamais d'une clé `section` du frontmatter. Seuls les groupes
411
+ DÉCLARÉS descendent dans le menu, dans l'ordre où ils sont écrits (`ROOT_GROUPS`,
412
+ `DocumentationService.ts:89`) : un dossier de `docs/` absent de cette liste — décisions
413
+ d'architecture, plan de publication, documents de pilotage — n'apparaît pas. C'est un choix, pas
414
+ un oubli : cette référence de mainteneur noyait le chemin de lecture. Les pages posées à la
415
+ racine de `docs/` ont leur propre liste (`ROOT_PAGES`, `DocumentationService.ts:103`) sous le
416
+ libellé « Pour commencer ». Les sections de module sont préfixées `mod-`, celles de la racine
417
+ `root-`.
418
+
419
+ ### Le cache — l'index, pas le contenu
420
+
421
+ `#ensureCache()` (`DocumentationService.ts:298`) sert son instantané tant qu'il est dans le
422
+ TTL, et rescanne sinon. Ce qui est caché tient dans `CacheEntry`
423
+ (`DocumentationService.ts:113`) : l'arbre, l'index `slug → doc`, et la table `chemin → slug`.
424
+
425
+ Le **contenu d'une page ne l'est jamais**. Chaque `getPage()` relit le fichier. La raison est
426
+ simple : le coût est celui d'une lecture froide sur un chemin d'administration, et la
427
+ contrepartie serait de servir un Markdown périmé à quelqu'un qui vient justement de le
428
+ corriger.
429
+
430
+ `invalidate()` (`DocumentationService.ts:177`) remet le cache à `null` — c'est la porte de
431
+ sortie quand un outil sait, lui, que le disque a bougé.
432
+
433
+ ## ⚙️ Configuration
434
+
435
+ Table dérivée du schéma Zod (`documentationConfigSchema`, `config.ts:134`), qui est la source
436
+ unique des défauts.
437
+
438
+ <!-- prettier-ignore -->
439
+ | Clé | Type | Défaut | Effet |
440
+ | --- | --- | --- | --- |
441
+ | `enabled` | booléen | `true` | drapeau d'activation déclaré au schéma (`config.ts:136`) |
442
+ | `scan.rootDir` | chaîne | `"docs"` | dossier transverse, relatif à la racine du projet |
443
+ | `scan.includeModules` | booléen | `true` | ajoute les `<module>/docs/` des modules chargés |
444
+ | `scan.includeInstalled` | booléen | `true` | ajoute les paquets installés non chargés (`config.ts:64`) |
445
+ | `scan.exclude` | liste de chaînes | `["session-retros", "node_modules", "dist"]` | segments de chemin ignorés (`config.ts:75`) |
446
+ | `repo.url` | chaîne | dépôt nodefony-core | base du lien « voir la source » |
447
+ | `repo.branch` | chaîne (option.) | — → branche git réelle, sinon `main` | branche du lien source |
448
+ | `repo.editPathPrefix` | `edit` \| `blob` \| `tree` | `"edit"` | segment GitHub : éditeur web, lecture, ou dossier |
449
+ | `cache.ttlMs` | entier ≥ 0 | `30000` | fraîcheur de l'index ; `0` = rescan à chaque requête (`config.ts:120`) |
450
+
451
+ Deux variables d'environnement écrasent la config, appliquées **après** le parse pour que le
452
+ schéma reste pur et sérialisable (`defineDocumentationConfig()`, `defineModuleConfig.ts:32`) :
453
+
454
+ | Variable | Écrase | Quand c'est utile |
455
+ | ------------------ | ------------- | -------------------------------------------------------- |
456
+ | `DOCS_REPO_URL` | `repo.url` | image de conteneur partagée entre plusieurs dépôts |
457
+ | `DOCS_REPO_BRANCH` | `repo.branch` | CI ou production détachée de git (pas de `.git` lisible) |
458
+
459
+ La validation a lieu au `onKernelRegister` (`index.ts:50`), **avant** l'instanciation du
460
+ service : une config invalide arrête le démarrage avec un message qui nomme le champ fautif,
461
+ plutôt qu'un `undefined.x` trois phases plus loin. Le JSON Schema publié par
462
+ `configSchema()` (`index.ts:40`) alimente le panneau de configuration Studio.
463
+
464
+ Enfin, le module est déclaré **non critique** (`index.ts:33`) : son échec ne tue jamais le
465
+ process — une application ne tombe pas parce que sa documentation est indisponible.
466
+
467
+ ## 🔌 Data plane — deux routes, deux formes
468
+
469
+ `DocumentationController` (`DocumentationController.ts:31`) est monté sur `/nodefony` et
470
+ respecte la convention d'administration : jamais de route mono-segment, toujours
471
+ `/nodefony/<module>/api/*`.
472
+
473
+ | Route | Rend | Contrat |
474
+ | --------------------------------------------- | ----------------------------------- | ----------------------------------- |
475
+ | `GET /nodefony/documentation/api/tree` | l'index complet, sections ordonnées | `IDocTree` (`IDocumentation.ts:57`) |
476
+ | `GET /nodefony/documentation/api/page/{slug}` | une page résolue | `IDocPage` (`IDocumentation.ts:67`) |
477
+
478
+ Les deux exigent un rôle (`@IsGranted`, `DocumentationController.ts:48`) : `ROLE_DEV` ou
479
+ `ROLE_SUPERVISOR`. C'est de la doc technique de framework — architecture, internals — pas du
480
+ contenu destiné à l'utilisateur final d'une application.
481
+
482
+ Les réponses d'erreur sont **génériques par principe** :
483
+
484
+ | Situation | Statut | Corps | Journal serveur |
485
+ | ------------------------ | ------ | --------------------------------------------------- | ----------------- |
486
+ | slug inconnu | 404 | `{ slug, error: "Document inconnu." }` | `DOC_NOT_FOUND` |
487
+ | slug rejeté par la garde | 404 | `{ slug, error: "Document inconnu." }` | `DOC_UNSAFE_SLUG` |
488
+ | lecture impossible | 500 | `{ slug, error: "Lecture de la page impossible." }` | l'erreur complète |
489
+ | index indisponible | 500 | `{ error: "Index de documentation indisponible." }` | l'erreur complète |
490
+
491
+ Les deux premiers cas rendent **la même chose au client**, volontairement : lui dire qu'un
492
+ slug a été « rejeté » plutôt qu'« introuvable », c'est lui confirmer que sa tentative a été
493
+ détectée — et lui apprendre où chercher. Le détail vit côté serveur, porté par un code machine
494
+ stable (`docCode`, `DocumentationError.ts:19`).
495
+
496
+ ## 🔐 Sécurité — la traversée de répertoire, bloquée deux fois
497
+
498
+ Le module lit des fichiers sur ordre d'un client. C'est la définition d'une surface de
499
+ traversée de répertoire — et un filtre de caractères ne suffit jamais à la fermer (encodages,
500
+ double-encodage, normalisation Unicode…).
501
+
502
+ La parade est un **changement de nature**, doublé d'un garde :
503
+
504
+ 1. **Allowlist par construction.** `getPage()` (`DocumentationService.ts:244`) cherche une
505
+ entrée par **égalité de slug** dans l'index, puis lit l'`absPath` mémorisé au scan
506
+ (`ScannedDoc`, `docScanner.ts:11`). Le slug n'est jamais concaténé à un chemin. Un slug
507
+ inconnu ne mène nulle part, quelle que soit sa forme.
508
+ 2. **Défense en profondeur, avant même la recherche.** `isSafeSlug()` (`slug.ts:39`) rejette
509
+ la chaîne vide, la longueur au-delà de 512 (`MAX_SLUG_LENGTH`, `slug.ts:28`), l'octet nul,
510
+ tout caractère hors du charset autorisé (`SAFE_SLUG`, `slug.ts:25`) et tout segment `..`,
511
+ même déguisé en séparateur `~`.
512
+
513
+ Le charset exclut `%`, donc `%2e%2e` est refusé comme n'importe quel autre caractère
514
+ inattendu — la question du double-décodage ne se pose pas.
515
+
516
+ Une troisième règle protège une surface différente : les variables `{{ }}` sont résolues par
517
+ des fournisseurs enregistrés côté serveur (`DocVarProvider`, `IDocumentation.ts:89`), et ne
518
+ doivent rendre que des valeurs **sûres** — version, identité git, information publique. Jamais
519
+ un secret, jamais un chemin absolu. Une variable inconnue est **laissée telle quelle**
520
+ (`#resolveVars()`, `DocumentationService.ts:541`), ce qui signale à l'auteur qu'il manque un
521
+ fournisseur au lieu de masquer le trou. Un fournisseur qui lève une exception ne casse pas le
522
+ rendu.
523
+
524
+ Enfin, le lien « voir la source » est assemblé depuis un chemin **relatif au dépôt**
525
+ (`#buildSourceUrl()`, `DocumentationService.ts:560`) : aucun chemin du système de fichiers ne
526
+ sort jamais du serveur.
527
+
528
+ ## ⚡ Performance & mémoire
529
+
530
+ Le module vit sur un chemin **froid** — un humain qui lit de la doc, pas dix mille requêtes
531
+ par seconde. La discipline reste la même.
532
+
533
+ - **Tout est alloué paresseusement.** L'index (`#cache`, `DocumentationService.ts:138`) et le
534
+ registre de variables (`#vars`, `DocumentationService.ts:140`) valent `null` jusqu'au
535
+ premier usage. Une application qui charge le module sans jamais ouvrir la doc ne paie ni un
536
+ objet, ni une lecture disque.
537
+ - **Le scan est mutualisé.** Les modules sont parcourus en parallèle, et le résultat sert
538
+ toutes les requêtes de la fenêtre de TTL. Le coût du disque suit le nombre de rescans, pas
539
+ le nombre de lecteurs.
540
+ - **Aucun écouteur, aucun minuteur.** L'expiration est calculée à la lecture (une
541
+ comparaison de dates), pas par un `setInterval` qui tournerait au repos.
542
+ - **Une seule lecture par page servie.** Frontmatter, variables et liens sont traités sur la
543
+ même chaîne, en un passage chacun.
544
+ - **Rien n'est retenu entre deux requêtes HTTP.** Le contrôleur est réinstancié et sans état ;
545
+ la mémoire du module est bornée par la taille de l'index, pas par le trafic.
546
+
547
+ Le seul vrai facteur de coût est le **nombre de fichiers scannés**, multiplié par la fréquence
548
+ des rescans. En développement, `cache.ttlMs: 0` échange ce coût contre l'immédiateté ; en
549
+ production, les 30 secondes par défaut le rendent négligeable.
550
+
551
+ ## 📡 Observabilité — Studio
552
+
553
+ - **Le portail** (`/nodefony/documentation`) consomme les deux routes : arbre à gauche,
554
+ sommaire à droite, page au centre. C'est le premier endroit où vérifier qu'une nouvelle page
555
+ est bien indexée, bien rangée, et que ses liens cliquent.
556
+ - **La carte du module** (`/nodefony/modules/documentation`) montre sa doc, ses symboles, ses
557
+ tests et sa configuration validée — le formulaire y est dérivé du JSON Schema publié par
558
+ `configSchema()` (`index.ts:40`), jamais écrit à la main.
559
+ - **Le journal** nomme chaque refus avec son code stable (`DOC_NOT_FOUND`, `DOC_UNSAFE_SLUG`).
560
+ Une page qui « n'apparaît pas » se diagnostique là, en une ligne.
561
+
562
+ Le module n'expose **rien de plus** : pas de compteur, pas de sonde. Ce qu'il fait est déjà
563
+ entièrement lisible dans ses deux réponses.
564
+
565
+ ## 🧩 Extension — trois points d'accroche
566
+
567
+ **1. Une variable `{{ }}`** — le point d'extension du contenu. `registerVar()`
568
+ (`DocumentationService.ts:138`) accepte un fournisseur **synchrone** qui rend une chaîne
569
+ (l'exemple du Démarrage rapide). Le module en enregistre trois lui-même au `onKernelReady`
570
+ (`index.ts:70`) : `version`, `branch`, `commit`.
571
+
572
+ **2. Les briques pures** — le point d'extension de l'outillage. `parseFrontmatter()`,
573
+ `scanDocsDir()`, `pathToSlug()`, `isSafeSlug()` sont exportées par le paquet et n'ont besoin
574
+ ni de Kernel ni de conteneur. Un générateur de site statique, un indexeur RAG ou un script de
575
+ vérification les réutilisent directement, avec exactement la sémantique du portail.
576
+
577
+ **3. Le data plane lui-même** — le point d'extension de l'affichage. Le module étant headless,
578
+ tout consommateur capable de lire du JSON peut se substituer au portail Studio sans qu'une
579
+ ligne change côté serveur.
580
+
581
+ ## ⚠️ Pièges
582
+
583
+ | Symptôme | Cause | Correction |
584
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
585
+ | Une nouvelle page n'apparaît pas | l'index est encore dans son TTL (30 s par défaut) | attendre, ou poser `cache.ttlMs: 0` en développement |
586
+ | Une page reste introuvable même après rescan | son dossier porte un segment exclu (`node_modules`, `dist`, `session-retros`) | déplacer la page, ou ajuster `scan.exclude` (`config.ts:75`) |
587
+ | `audience` sans effet, page visible par tous | valeur hors `DocAudience` — silencieusement filtrée (`#toPageRef()`) | n'utiliser que `developer` · `devops` · `supervisor` · `admin` |
588
+ | `status` absent de l'arbre alors qu'il est écrit | valeur hors `DocStatus` — ramenée à « absent » (`#coerceStatus()`) | s'en tenir aux cinq statuts du contrat |
589
+ | La date de la page ne s'affiche pas | clé `last-updated` au lieu de `updated` — le service ne lit que `updated` | renommer la clé en `updated` |
590
+ | Un lien relatif reste inerte dans le portail | la cible n'est pas indexée (`CLAUDE.md`, `MEMORY.md`, fichier supprimé) → laissée telle quelle | lier une page de doc, ou accepter le lien inerte |
591
+ | Un lien de card ne mène nulle part | `href` d'une fence typée mal compté (le JSON est traduit comme le markdown, mais pas deviné) | vérifier le chemin relatif ; le banc de corpus l'attrape |
592
+ | Une ancre `#section` marche sur GitHub, morte dans le portail | divergence entre `slugifyHeading()` (`DocToc.tsx:54`) et le gate `anchor-inpage` | garder les deux implémentations identiques — accents conservés |
593
+ | Le bouton « voir la source » pointe vers un mauvais fichier | le frontmatter `source:` **écrase** le chemin réel dans `#buildSourceUrl()` | tenir `source:` à jour, ou l'omettre pour laisser le chemin réel gagner |
594
+ | Le lien source pointe vers une branche absente en production | pas de `.git` lisible dans le conteneur → repli sur `main` | poser `DOCS_REPO_BRANCH` (ou `repo.branch`) |
595
+ | Une clé de frontmatter n'a aucun effet | seules `title` · `audience` · `version` · `status` · `updated` · `source` sont consommées | comportement voulu : les autres clés servent au RAG |
596
+ | Un frontmatter multi-lignes (` | `) casse le titre | non supporté par le parseur plat (`frontmatter.ts:51`) | rester en YAML plat : scalaire ou liste |
597
+
598
+ ## 🧪 Tests & couverture
599
+
600
+ Cinq fichiers, tous **unitaires** : les briques pures se testent sans serveur, sans Kernel et
601
+ sans conteneur — c'est précisément la raison de les avoir isolées. Les compteurs exacts vivent
602
+ dans la carte de l'aperçu, régénérés depuis les résultats réels.
603
+
604
+ | Banc | Ce qui est réellement exercé |
605
+ | ---------------------- | -------------------------------------------------------------------------------------------------------- |
606
+ | `frontmatter.test.ts` | scalaires, guillemets, listes inline et en bloc, clé vide, commentaires, BOM, CRLF, ligne mal formée |
607
+ | `slug.test.ts` | forme des slugs racine et module, et surtout les **refus** : vide, > 512, octet nul, `/`, `\`, `..`, `%` |
608
+ | `docScanner.test.ts` | dossier absent → `[]`, filtre `.md`, segments exclus, tri, groupe, titre humanisé, tag de source |
609
+ | `linkResolver.test.ts` | lien plat, remontée profonde, module voisin, ancre préservée, cible non indexée, fences typées |
610
+ | `corpusLinks.test.ts` | le **corpus réel** du dépôt : liens morts, unicité des slugs, hubs atteignables |
611
+
612
+ Le dernier mérite qu'on s'y arrête. Les autres travaillent sur un index fabriqué ; celui-là
613
+ parcourt les vraies pages et attrape ce qu'aucun double ne peut voir : un `../` mal compté,
614
+ une page renommée, un lien vers un fichier supprimé (`analyze()`, `corpusLinks.test.ts:120`).
615
+
616
+ Il porte un **cliquet** : `LEGACY_BROKEN_LINKS` (`corpusLinks.test.ts:147`) liste les pages
617
+ pas encore reprises au standard, qui traînent des liens faux hérités. Deux assertions
618
+ l'encadrent — les pages hors liste ne doivent avoir **aucun** lien mort, et une page de la
619
+ liste qui a été réparée doit en **sortir**. Sans cette seconde garde, la liste se relâcherait
620
+ en silence et une régression future passerait inaperçue. La règle est simple : cette liste ne
621
+ peut que rétrécir.
622
+
623
+ **Ce qui n'est pas couvert, et qu'il faut savoir :**
624
+
625
+ - **Ni le service ni le contrôleur n'ont de test unitaire** : ils dépendent du Kernel et du
626
+ conteneur. Le cache, le dédoublonnage des paquets installés, la résolution des variables et
627
+ les réponses HTTP sont vérifiés en **intégration sur serveur réel** (`curl` sur les deux
628
+ routes), pas par cette suite.
629
+ - **Pas de banc de charge ni de test mémoire dédiés** — le module vit sur un chemin froid.
630
+ Pour dimensionner, le skill `nodefony-load-test` ; pour la mémoire du pipeline,
631
+ `nodefony-check-memory-health`.
632
+ - **Pas de test d'attaque** (`*.attack.test.ts`) : les refus de slug sont couverts par les
633
+ tests unitaires de `isSafeSlug()`, pas par une campagne offensive.
634
+
635
+ Couverture : `npm run coverage` dans `@nodefony/documentation`.
636
+
637
+ ## 🔗 Pour aller plus loin
638
+
639
+ - ⬆️ **Retour au hub** : [Documentation — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
640
+ - 📐 **La décision fondatrice** : [ADR-0001 — emplacement hybride de la doc](../../../../../docs/adr/0001-docs-modules-emplacement-hybride.md)
641
+ - 🖥️ **Le consommateur** : [Studio — l'application d'administration](../../studio/docs/index.md)
642
+ - 🧰 **Écrire le contrôleur qui consomme le data plane** : [Controller](../../framework/docs/controller.md)
643
+ - 🔐 **Le rôle exigé par les deux routes** : [Autorisation](../../security/docs/authorization.md)
644
+ - ⚙️ **Où la config du module est validée** : [Configuration](../../../../../docs/architecture/configuration.md) ·
645
+ [cycle de démarrage du kernel](../../../../../docs/architecture/cycle-boot-kernel.md)
646
+ - Les signatures exactes ne sont jamais recopiées ici : elles vivent dans le graphe symbolique
647
+ `.ai/symbols.json`, régénéré depuis les TSDoc du code.