@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,421 @@
1
+ import { metaList, metaString, parseFrontmatter } from "../src/frontmatter.js";
2
+ import { isSafeSlug } from "../src/slug.js";
3
+ import { scanDocsDir } from "../src/docScanner.js";
4
+ import { rewriteInternalLinks } from "../src/linkResolver.js";
5
+ import { searchDocs, splitSearchTerms } from "../src/search.js";
6
+ import { DocNotFoundError, DocUnsafeSlugError } from "../src/errors/DocumentationError.js";
7
+ import { GitService, Service, stripTrailingSlashes } from "nodefony";
8
+ import { readFile } from "node:fs/promises";
9
+ import { readdirSync, realpathSync, statSync } from "node:fs";
10
+ import { dirname, join, relative } from "node:path";
11
+ //#region nodefony/service/DocumentationService.ts
12
+ const serviceName = "documentation";
13
+ /** Personas connues — descriptions affichées par le sélecteur de vue Studio. */
14
+ const AUDIENCES = [
15
+ {
16
+ key: "developer",
17
+ label: "Développeur",
18
+ desc: "Doc technique : architecture, contrats, API internes."
19
+ },
20
+ {
21
+ key: "devops",
22
+ label: "DevOps",
23
+ desc: "Déploiement, cluster, scaling, backplane (fond de panier)."
24
+ },
25
+ {
26
+ key: "supervisor",
27
+ label: "Superviseur",
28
+ desc: "Observabilité : santé, métriques temps réel, alertes."
29
+ },
30
+ {
31
+ key: "admin",
32
+ label: "Admin",
33
+ desc: "Accès à toute la documentation."
34
+ }
35
+ ];
36
+ const VALID_AUDIENCES = new Set(AUDIENCES.map((a) => a.key));
37
+ const VALID_STATUS = /* @__PURE__ */ new Set([
38
+ "stable",
39
+ "draft",
40
+ "temporary",
41
+ "experimental",
42
+ "deprecated"
43
+ ]);
44
+ /**
45
+ * Les groupes racine publiés, **dans l'ordre du menu**, avec leur libellé.
46
+ *
47
+ * L'ordre est PÉDAGOGIQUE, jamais alphabétique, et il part du geste : on fait une
48
+ * première fois en étant guidé (tutoriels), on refait seul sur un besoin précis
49
+ * (guides), puis on comprend ce qui se passait dessous (architecture). Un tri
50
+ * alphabétique mettait « ADR » en tête et « Tutoriels » en cinquième position —
51
+ * l'inverse exact du chemin de lecture.
52
+ *
53
+ * ⚠️ **C'est la SEULE définition de ce périmètre.** Le générateur du site public
54
+ * (`scripts/build-docs-site.mjs`) l'importe depuis le `dist` de ce module — il en
55
+ * importait déjà les briques de scan — au lieu d'en tenir une copie. Une copie a
56
+ * existé ici, sous le prétexte d'une frontière de paquets que le script
57
+ * franchissait pourtant déjà ; elle avait commencé à diverger. N'en réintroduire
58
+ * aucune : le portail et le site doivent publier les MÊMES sections, sinon un
59
+ * lecteur trouve dans l'un ce que l'autre lui cache.
60
+ */
61
+ const ROOT_GROUPS = [
62
+ {
63
+ group: "tutoriels",
64
+ label: "Tutoriels"
65
+ },
66
+ {
67
+ group: "guides",
68
+ label: "Guides"
69
+ },
70
+ {
71
+ group: "architecture",
72
+ label: "Architecture"
73
+ }
74
+ ];
75
+ /**
76
+ * Pages de `docs/` (à la racine, hors sous-dossier) publiées dans le menu, dans
77
+ * cet ordre. Le reste de ce dossier est du PILOTAGE — carte des phases, README du
78
+ * corpus, essai sur l'outillage : utile au mainteneur du framework, illisible
79
+ * pour qui construit une application.
80
+ *
81
+ * Comme {@link ROOT_GROUPS}, c'est la seule définition : le site public l'importe.
82
+ */
83
+ const ROOT_PAGES = [
84
+ "index",
85
+ "demarrer",
86
+ "lexique"
87
+ ];
88
+ /** Libellé du groupe qui porte les pages de `docs/` elles-mêmes. */
89
+ const ROOT_PAGES_LABEL = "Pour commencer";
90
+ /**
91
+ * Service de documentation Nodefony — **headless** : produit l'index transverse
92
+ * et le contenu résolu des pages, sans rendre aucun HTML (le front Studio, un
93
+ * générateur statique ou le RAG le consomment).
94
+ *
95
+ * Sources scannées (config `scan`) : le dossier `docs/` racine (transverse) +
96
+ * les `<module>/docs/*.md` co-localisés (ADR-0001) si `includeModules`.
97
+ *
98
+ * Perf/mémoire (règle absolue) : tout est lazy. L'index est construit au 1ᵉʳ
99
+ * accès et caché avec un TTL (`cache.ttlMs`) — le scan FS n'est PAS refait à
100
+ * chaque requête. Le registre de variables `{{ }}` est alloué au 1ᵉʳ
101
+ * `registerVar`. 0 alloc par requête hors la lecture froide d'une page (chemin
102
+ * admin, pas le hot path applicatif).
103
+ */
104
+ var DocumentationService = class extends Service {
105
+ module;
106
+ /** Snapshot caché (index + arbre) — `null` tant qu'aucun scan. */
107
+ #cache = null;
108
+ /** Fournisseurs de variables `{{ }}` — `null` tant qu'aucun enregistré. */
109
+ #vars = null;
110
+ constructor(module) {
111
+ super(serviceName, module.container, null, module.options ?? {});
112
+ this.module = module;
113
+ }
114
+ log(pci, severity, msgid, msg) {
115
+ if (!msgid) msgid = `\x1b[36mDOCUMENTATION\x1b[0m`;
116
+ return super.log(pci, severity, msgid, msg);
117
+ }
118
+ /** Config validée du module (réassignée à `module.options` au onKernelRegister). */
119
+ #config() {
120
+ return this.module.options;
121
+ }
122
+ /** Racine du projet (où vit `docs/` transverse). */
123
+ #projectRoot() {
124
+ return this.kernel?.path ?? process.cwd();
125
+ }
126
+ registerVar(name, provider) {
127
+ if (this.#vars === null) this.#vars = /* @__PURE__ */ new Map();
128
+ this.#vars.set(name, provider);
129
+ }
130
+ invalidate() {
131
+ this.#cache = null;
132
+ }
133
+ async getTree() {
134
+ return (await this.#ensureCache()).tree;
135
+ }
136
+ /**
137
+ * Cherche `query` dans le corpus — titres ET corps — et rend des EXTRAITS.
138
+ *
139
+ * Pourquoi une recherche à part, et pas un filtre de l'arbre : filtrer le menu
140
+ * ne répond qu'à « quelle page s'appelle ainsi ? ». La question réelle est
141
+ * « où est-ce expliqué ? », dont la réponse est dans le CORPS des pages. Une
142
+ * recherche qui ne lit que les titres laisse croire qu'un sujet n'est pas
143
+ * documenté alors qu'il l'est, dans une page dont le nom ne le dit pas.
144
+ *
145
+ * Tout est borné : le nombre de pages rendues, le nombre d'extraits par page,
146
+ * la longueur d'un extrait. Le total AVANT bornage est rendu à part
147
+ * (`matched`), pour que « 20 résultats » ne se lise pas comme « il n'y en a
148
+ * que 20 ».
149
+ *
150
+ * @param query - la saisie brute de l'utilisateur.
151
+ * @param limit - nombre maximal de pages rendues (défaut 20).
152
+ * @returns les pages retenues, leurs extraits, et ce qui a été balayé.
153
+ */
154
+ async search(query, limit = 20) {
155
+ if (splitSearchTerms(query).length === 0) return {
156
+ query,
157
+ terms: [],
158
+ scanned: 0,
159
+ matched: 0,
160
+ hits: []
161
+ };
162
+ const { tree, index } = await this.#ensureCache();
163
+ const sectionOf = /* @__PURE__ */ new Map();
164
+ for (const s of tree.sections) for (const p of s.pages) sectionOf.set(p.slug, s.label);
165
+ const corpus = [];
166
+ for (const doc of index.values()) {
167
+ const sectionLabel = sectionOf.get(doc.slug);
168
+ if (sectionLabel === void 0) continue;
169
+ let raw;
170
+ try {
171
+ raw = await readFile(doc.absPath, "utf8");
172
+ } catch {
173
+ continue;
174
+ }
175
+ const { body } = parseFrontmatter(raw);
176
+ corpus.push({
177
+ slug: doc.slug,
178
+ title: doc.title,
179
+ navTitle: doc.navTitle,
180
+ sectionLabel,
181
+ body
182
+ });
183
+ }
184
+ return searchDocs(corpus, query, limit);
185
+ }
186
+ async getPage(slug) {
187
+ if (!isSafeSlug(slug)) throw new DocUnsafeSlugError(slug);
188
+ const { index, byPath } = await this.#ensureCache();
189
+ const doc = index.get(slug);
190
+ if (!doc) throw new DocNotFoundError(slug);
191
+ const raw = await readFile(doc.absPath, "utf8");
192
+ const { meta, body } = parseFrontmatter(raw);
193
+ const repoRel = relative(this.#projectRoot(), doc.absPath).replace(/\\/g, "/");
194
+ const source = metaString(meta, "source") ?? repoRel;
195
+ return {
196
+ slug: doc.slug,
197
+ title: metaString(meta, "title") ?? doc.title,
198
+ version: metaString(meta, "version") ?? "doc",
199
+ status: this.#coerceStatus(metaString(meta, "status")),
200
+ updated: metaString(meta, "updated"),
201
+ source,
202
+ sourceUrl: this.#buildSourceUrl(source),
203
+ markdown: this.#resolveLinks(this.#resolveVars(body), doc, byPath)
204
+ };
205
+ }
206
+ /**
207
+ * Traduit les liens internes de la page en slugs navigables.
208
+ *
209
+ * Les pages se lient par chemin relatif (lisible sur GitHub et dans l'éditeur) ;
210
+ * le portail navigue par slug. Seul le serveur connaît la table chemin → slug,
211
+ * donc la traduction se fait ici — sans quoi toute remontée (`../index.md`)
212
+ * arrive au client comme une ancre morte.
213
+ */
214
+ #resolveLinks(markdown, from, byPath) {
215
+ const root = this.#projectRoot();
216
+ const fromDir = relative(root, dirname(from.absPath)).replace(/\\/g, "/");
217
+ return rewriteInternalLinks(markdown, {
218
+ fromDir,
219
+ toSlug: (repoRel) => byPath.get(repoRel)
220
+ });
221
+ }
222
+ /** Sert le cache si frais (TTL), sinon rescanne et reconstruit l'arbre. */
223
+ async #ensureCache() {
224
+ const ttl = this.#config().cache.ttlMs;
225
+ const now = Date.now();
226
+ if (this.#cache && ttl > 0 && now - this.#cache.at < ttl) return this.#cache;
227
+ const docs = await this.#scanAll();
228
+ const index = /* @__PURE__ */ new Map();
229
+ const byPath = /* @__PURE__ */ new Map();
230
+ const root = this.#projectRoot();
231
+ for (const d of docs) {
232
+ index.set(d.slug, d);
233
+ byPath.set(relative(root, d.absPath).replace(/\\/g, "/"), d.slug);
234
+ }
235
+ const tree = {
236
+ generatedAt: new Date(now).toISOString(),
237
+ audiences: [...AUDIENCES],
238
+ sections: this.#buildSections(docs)
239
+ };
240
+ this.#cache = {
241
+ at: now,
242
+ tree,
243
+ index,
244
+ byPath
245
+ };
246
+ return this.#cache;
247
+ }
248
+ /** Scanne la racine + (si activé) les `<module>/docs/` de chaque module. */
249
+ async #scanAll() {
250
+ const cfg = this.#config();
251
+ const exclude = cfg.scan.exclude;
252
+ const root = this.#projectRoot();
253
+ const out = [];
254
+ out.push(...await scanDocsDir(join(root, cfg.scan.rootDir), { kind: "root" }, exclude));
255
+ if (cfg.scan.includeModules) {
256
+ const modules = this.kernel?.getModules?.() ?? {};
257
+ const scans = Object.values(modules).filter((m) => m && !m.isApp && m.path).map((m) => scanDocsDir(join(m.path, "docs"), {
258
+ kind: "module",
259
+ module: m.name
260
+ }, exclude));
261
+ for (const docs of await Promise.all(scans)) out.push(...docs);
262
+ }
263
+ if (cfg.scan.includeInstalled) {
264
+ const seen = new Set(out.filter((d) => d.source.kind === "module").map((d) => d.source.module));
265
+ for (const [name, dir] of this.#installedDocDirs(root)) {
266
+ if (seen.has(name)) continue;
267
+ out.push(...await scanDocsDir(dir, {
268
+ kind: "module",
269
+ module: name
270
+ }, exclude));
271
+ }
272
+ }
273
+ return out;
274
+ }
275
+ /**
276
+ * Dossiers `docs/` des paquets Nodefony **installés** (chargés ou non).
277
+ *
278
+ * Un module qu'on n'a pas encore activé est justement celui dont on lit la doc
279
+ * — sans ça, le portail renvoie dans le vide sur `redis`, `mongoose`… tant
280
+ * qu'ils ne sont pas dans le manifeste.
281
+ *
282
+ * Les chemins sont résolus en **real-path** : en dépôt workspace,
283
+ * `node_modules/@nodefony/x` est un lien vers `src/packages/@nodefony/x`, et
284
+ * c'est la source qui doit indexer (sinon les liens entre pages ne se
285
+ * résolvent pas — deux chemins pour un même fichier).
286
+ */
287
+ #installedDocDirs(root) {
288
+ const found = /* @__PURE__ */ new Map();
289
+ const add = (name, dir) => {
290
+ try {
291
+ if (!statSync(dir).isDirectory()) return;
292
+ found.set(name, realpathSync(dir));
293
+ } catch {}
294
+ };
295
+ add("nodefony", join(root, "node_modules/nodefony/docs"));
296
+ const scope = join(root, "node_modules/@nodefony");
297
+ let entries = [];
298
+ try {
299
+ entries = readdirSync(scope);
300
+ } catch {
301
+ return found;
302
+ }
303
+ for (const pkg of entries) add(pkg, join(scope, pkg, "docs"));
304
+ return found;
305
+ }
306
+ /** Regroupe les docs scannés en sections (racine par dossier, module par module). */
307
+ #buildSections(docs) {
308
+ const rootGroups = /* @__PURE__ */ new Map();
309
+ const moduleGroups = /* @__PURE__ */ new Map();
310
+ for (const d of docs) if (d.source.kind === "root") (rootGroups.get(d.group) ?? setGet(rootGroups, d.group)).push(d);
311
+ else {
312
+ const key = d.source.module;
313
+ (moduleGroups.get(key) ?? setGet(moduleGroups, key)).push(d);
314
+ }
315
+ const rootDocs = (rootGroups.get("racine") ?? []).filter((d) => ROOT_PAGES.includes(d.relPath.replace(/\.md$/i, "")));
316
+ const gettingStarted = rootDocs.length ? [{
317
+ id: "root-pour-commencer",
318
+ label: ROOT_PAGES_LABEL,
319
+ pages: ROOT_PAGES.map((n) => rootDocs.find((d) => d.relPath.replace(/\.md$/i, "") === n)).filter((d) => Boolean(d)).map((d) => this.#toPageRef(d))
320
+ }] : [];
321
+ const rootSections = ROOT_GROUPS.map(({ group, label }) => {
322
+ const pages = rootGroups.get(group);
323
+ return pages?.length ? {
324
+ id: `root-${group.replace(/\//g, "~")}`,
325
+ label,
326
+ pages: this.#orderPages(pages)
327
+ } : null;
328
+ }).filter((s) => s !== null);
329
+ const moduleSections = [...moduleGroups.entries()].sort(([a], [b]) => {
330
+ if (a === b) return 0;
331
+ if (a === "nodefony") return -1;
332
+ if (b === "nodefony") return 1;
333
+ return a.localeCompare(b);
334
+ }).map(([mod, pages]) => ({
335
+ id: `mod-${mod}`,
336
+ label: mod === "nodefony" ? "Cœur" : mod,
337
+ module: mod,
338
+ pages: this.#orderPages(pages)
339
+ }));
340
+ return [
341
+ ...gettingStarted,
342
+ ...rootSections,
343
+ ...moduleSections
344
+ ];
345
+ }
346
+ /**
347
+ * Ordonne les pages d'une section : le **hub en premier**, le reste ensuite.
348
+ *
349
+ * Un `index.md` trié alphabétiquement atterrit au milieu de ses propres pages
350
+ * (entre `headers` et `lexique` pour la sécurité) — le point d'entrée devient
351
+ * alors invisible. Le hub ouvre sa section ; c'est le chemin de lecture normal.
352
+ */
353
+ #orderPages(pages) {
354
+ const refs = pages.map((p) => this.#toPageRef(p));
355
+ if (!refs.some((r) => r.isHub)) {
356
+ const readme = pages.findIndex((p) => /(^|\/)readme\.md$/i.test(p.relPath));
357
+ if (readme !== -1) refs[readme].isHub = true;
358
+ }
359
+ return refs.sort((a, b) => {
360
+ if (a.isHub !== b.isHub) return a.isHub ? -1 : 1;
361
+ return a.navTitle.localeCompare(b.navTitle);
362
+ });
363
+ }
364
+ /** Convertit un doc scanné en référence d'arbre (métadonnées seules). */
365
+ #toPageRef(d) {
366
+ const audience = metaList(d.meta, "audience").filter((a) => VALID_AUDIENCES.has(a));
367
+ const isHub = /(^|\/)index\.md$/i.test(d.relPath);
368
+ const ref = {
369
+ slug: d.slug,
370
+ title: d.title,
371
+ navTitle: d.navTitle,
372
+ audience,
373
+ isHub
374
+ };
375
+ const version = metaString(d.meta, "version");
376
+ if (version) ref.version = version;
377
+ const status = this.#coerceStatus(metaString(d.meta, "status"));
378
+ if (status) ref.status = status;
379
+ return ref;
380
+ }
381
+ /** Restreint une valeur libre au type `DocStatus` (sinon `undefined`). */
382
+ #coerceStatus(value) {
383
+ return value && VALID_STATUS.has(value) ? value : void 0;
384
+ }
385
+ /**
386
+ * Remplace les variables `{{ name }}` par la valeur de leur fournisseur
387
+ * enregistré. Variable inconnue → laissée telle quelle (signale à l'auteur
388
+ * qu'il manque un provider, plutôt que de masquer silencieusement).
389
+ */
390
+ #resolveVars(markdown) {
391
+ if (this.#vars === null || this.#vars.size === 0) return markdown;
392
+ const vars = this.#vars;
393
+ return markdown.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (whole, name) => {
394
+ const provider = vars.get(name);
395
+ if (!provider) return whole;
396
+ try {
397
+ return provider();
398
+ } catch {
399
+ return whole;
400
+ }
401
+ });
402
+ }
403
+ /**
404
+ * Construit le lien « Modifier sur GitHub » d'une page à partir d'un chemin
405
+ * RELATIF au repo. Branche = config explicite, sinon branche git réelle
406
+ * (`GitService`), sinon `main`. N'expose jamais de chemin FS absolu.
407
+ */
408
+ #buildSourceUrl(repoRelPath) {
409
+ const repo = this.#config().repo;
410
+ const branch = repo.branch ?? (GitService.branch(this.#projectRoot()) || "main");
411
+ return `${stripTrailingSlashes(repo.url)}/${repo.editPathPrefix}/${branch}/${repoRelPath}`;
412
+ }
413
+ };
414
+ /** Crée et insère une liste vide dans une Map, et la retourne (helper groupBy). */
415
+ function setGet(map, key) {
416
+ const arr = [];
417
+ map.set(key, arr);
418
+ return arr;
419
+ }
420
+ //#endregion
421
+ export { ROOT_GROUPS, ROOT_PAGES, DocumentationService as default };
@@ -0,0 +1,58 @@
1
+ import { metaString, parseFrontmatter } from "./frontmatter.js";
2
+ import { pathToSlug } from "./slug.js";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ //#region nodefony/src/docScanner.ts
6
+ /** `01-vue-ensemble.md` → `Vue Ensemble` (retire préfixe numérique + sépare). */
7
+ function humanizeFilename(base) {
8
+ return base.replace(/\.md$/i, "").replace(/^\d+[-_]/, "").replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
9
+ }
10
+ /** `true` si un chemin relatif contient un segment exclu (ex. `node_modules`). */
11
+ function isExcluded(relPath, exclude) {
12
+ return relPath.split(/[/\\]/).some((seg) => exclude.includes(seg));
13
+ }
14
+ /**
15
+ * Scanne récursivement un dossier de docs et retourne les fichiers `.md`
16
+ * trouvés, frontmatter lu, triés par chemin relatif.
17
+ *
18
+ * Best-effort : un dossier absent (`ENOENT`) renvoie `[]` (pas d'erreur) ; un
19
+ * fichier illisible garde son titre humanisé (frontmatter ignoré).
20
+ *
21
+ * @param baseDir - dossier racine à scanner (absolu).
22
+ * @param source - origine taguée sur chaque doc (racine ou module).
23
+ * @param exclude - noms de segments de chemin à ignorer.
24
+ * @returns liste des docs trouvés (vide si dossier absent).
25
+ */
26
+ async function scanDocsDir(baseDir, source, exclude = []) {
27
+ let entries;
28
+ try {
29
+ entries = await readdir(baseDir, { recursive: true });
30
+ } catch {
31
+ return [];
32
+ }
33
+ const mdFiles = entries.filter((rel) => rel.toLowerCase().endsWith(".md") && !isExcluded(rel, exclude));
34
+ return (await Promise.all(mdFiles.map(async (rel) => {
35
+ const relPosix = rel.replace(/\\/g, "/");
36
+ const parts = relPosix.split("/");
37
+ const base = parts[parts.length - 1];
38
+ const group = parts.slice(0, -1).join("/") || "racine";
39
+ const absPath = join(baseDir, rel);
40
+ let meta = {};
41
+ try {
42
+ const raw = await readFile(absPath, "utf8");
43
+ meta = parseFrontmatter(raw).meta;
44
+ } catch {}
45
+ return {
46
+ slug: pathToSlug(source, relPosix),
47
+ relPath: relPosix,
48
+ absPath,
49
+ source,
50
+ group,
51
+ meta,
52
+ title: metaString(meta, "title") ?? humanizeFilename(base),
53
+ navTitle: metaString(meta, "navTitle") ?? metaString(meta, "title") ?? humanizeFilename(base)
54
+ };
55
+ }))).sort((a, b) => a.relPath.localeCompare(b.relPath));
56
+ }
57
+ //#endregion
58
+ export { scanDocsDir };
@@ -0,0 +1,45 @@
1
+ import { nodefonyError } from "nodefony";
2
+ //#region nodefony/src/errors/DocumentationError.ts
3
+ /**
4
+ * Erreur typée du module `@nodefony/documentation`.
5
+ *
6
+ * Étend `nodefonyError` (le wrapper d'erreur du core, renommé pour ne pas
7
+ * entrer en collision avec `globalThis.Error`). Porte un `code` machine stable
8
+ * pour que le data plane distingue les cas sans parser le message (Zero Trust :
9
+ * le message détaillé reste serveur, le client ne voit qu'un code + un message
10
+ * générique).
11
+ */
12
+ var DocumentationError = class extends nodefonyError {
13
+ /**
14
+ * Code machine stable (ex. `DOC_NOT_FOUND`, `DOC_UNSAFE_SLUG`).
15
+ *
16
+ * Nommé `docCode` (pas `code`) : `nodefonyError` réserve déjà `code?: number`
17
+ * (statut HTTP). On ne shadow pas un champ numérique du parent par un string.
18
+ */
19
+ docCode;
20
+ constructor(message, docCode = "DOC_ERROR") {
21
+ super(message);
22
+ this.name = "DocumentationError";
23
+ this.docCode = docCode;
24
+ }
25
+ };
26
+ /** Slug demandé absent de l'allowlist construite par le scan (404 logique). */
27
+ var DocNotFoundError = class extends DocumentationError {
28
+ constructor(slug) {
29
+ super(`Document inconnu : "${slug}"`, "DOC_NOT_FOUND");
30
+ this.name = "DocNotFoundError";
31
+ }
32
+ };
33
+ /**
34
+ * Slug rejeté car potentiellement dangereux (traversée de répertoire, segment
35
+ * `..`, caractère hors charset). Sécurité : on ne touche JAMAIS au FS avec un
36
+ * tel slug.
37
+ */
38
+ var DocUnsafeSlugError = class extends DocumentationError {
39
+ constructor(slug) {
40
+ super(`Slug rejeté (non sûr) : "${slug}"`, "DOC_UNSAFE_SLUG");
41
+ this.name = "DocUnsafeSlugError";
42
+ }
43
+ };
44
+ //#endregion
45
+ export { DocNotFoundError, DocUnsafeSlugError, DocumentationError };
@@ -0,0 +1,63 @@
1
+ //#region nodefony/src/frontmatter.ts
2
+ /** Retire une paire de quotes simples ou doubles entourant la valeur. */
3
+ function unquote(value) {
4
+ return value.replace(/^["']|["']$/g, "");
5
+ }
6
+ /** Parse une liste inline `[a, "b", c]` → `["a","b","c"]` (vide → `[]`). */
7
+ function parseInlineList(raw) {
8
+ const inner = raw.slice(1, -1).trim();
9
+ if (inner === "") return [];
10
+ return inner.split(",").map((s) => unquote(s.trim())).filter((s) => s !== "");
11
+ }
12
+ /**
13
+ * Sépare le bloc frontmatter du corps markdown et parse les métadonnées.
14
+ *
15
+ * @param raw - contenu brut du fichier `.md`.
16
+ * @returns `{ meta, body }` — `meta` vide et `body = raw` s'il n'y a pas de bloc.
17
+ */
18
+ function parseFrontmatter(raw) {
19
+ const m = /^?\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
20
+ if (!m) return {
21
+ meta: {},
22
+ body: raw
23
+ };
24
+ const meta = {};
25
+ const lines = m[1].split(/\r?\n/);
26
+ for (let i = 0; i < lines.length; i++) {
27
+ const line = lines[i];
28
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
29
+ const kv = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line);
30
+ if (!kv) continue;
31
+ const key = kv[1];
32
+ const value = kv[2].trim();
33
+ if (value === "") {
34
+ const items = [];
35
+ while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) items.push(unquote(lines[++i].replace(/^\s*-\s+/, "").trim()));
36
+ meta[key] = items;
37
+ continue;
38
+ }
39
+ if (value.startsWith("[") && value.endsWith("]")) {
40
+ meta[key] = parseInlineList(value);
41
+ continue;
42
+ }
43
+ meta[key] = unquote(value);
44
+ }
45
+ return {
46
+ meta,
47
+ body: m[2]
48
+ };
49
+ }
50
+ /** Lit une clé de frontmatter en string (1er élément si liste), ou `undefined`. */
51
+ function metaString(meta, key) {
52
+ const v = meta[key];
53
+ if (Array.isArray(v)) return v[0];
54
+ return v;
55
+ }
56
+ /** Lit une clé de frontmatter en string[] (wrappe un scalaire), ou `[]`. */
57
+ function metaList(meta, key) {
58
+ const v = meta[key];
59
+ if (v === void 0) return [];
60
+ return Array.isArray(v) ? v : [v];
61
+ }
62
+ //#endregion
63
+ export { metaList, metaString, parseFrontmatter };
@@ -0,0 +1,68 @@
1
+ //#region nodefony/src/linkResolver.ts
2
+ /**
3
+ * Réécriture des liens internes d'une page de doc en **slugs**.
4
+ *
5
+ * Une page vit sur le disque et lie ses voisines par chemin relatif — c'est ce
6
+ * qui la rend lisible sur GitHub et dans un éditeur : `[CORS](cors.md)`,
7
+ * `[Documentation](../../../../../docs/index.md)`. Mais le portail ne navigue
8
+ * pas par chemin : il navigue par **slug** (`mod~security~cors`), parce qu'un
9
+ * slug est une clé d'allowlist et jamais un chemin FS (anti-traversée, cf
10
+ * {@link ../src/slug}).
11
+ *
12
+ * Sans traduction, seuls les liens PLATS fonctionnaient dans Studio : toute
13
+ * remontée (`../index.md`) tombait en ancre HTML morte. La résolution appartient
14
+ * au serveur, seul à connaître la table chemin → slug ; le client n'a aucun
15
+ * moyen de deviner à quel fichier `../../..` correspond.
16
+ *
17
+ * Le lien conserve l'extension `.md` après réécriture (`mod~security~cors.md`) :
18
+ * le rendu markdown reconnaît un lien interne à cette extension, et un slug
19
+ * seul serait indistinguable d'une URL relative quelconque.
20
+ */
21
+ /**
22
+ * Liens markdown `[texte](cible)` — on ne touche QUE les cibles `.md`.
23
+ * Exclus : URL absolues (`http:`, `mailto:`), ancres pures (`#section`), et
24
+ * tout ce qui n'est pas un fichier markdown.
25
+ */
26
+ const MD_LINK = /\]\((?!https?:|mailto:|#)([^)\s]{1,512}?\.md)(#[^)\s]{0,512})?\)/gi;
27
+ /**
28
+ * Cible d'un bloc déclaratif : `"href": "../x.md"` dans un JSON de fence typée
29
+ * (`nodefony-cards`…). Même règle que les liens markdown — seules les cibles
30
+ * `.md` internes sont traduites.
31
+ */
32
+ const JSON_HREF = /"href"\s*:\s*"(?!https?:|mailto:|#)([^"\s]{1,512}?\.md)"/gi;
33
+ /** Résout un chemin relatif POSIX contre le dossier d'une page. */
34
+ function resolveRelative(fromDir, href) {
35
+ const out = [...href.startsWith("/") ? [] : fromDir.split("/").filter(Boolean)];
36
+ for (const seg of href.split("/")) {
37
+ if (seg === "" || seg === ".") continue;
38
+ if (seg === "..") out.pop();
39
+ else out.push(seg);
40
+ }
41
+ return out.join("/");
42
+ }
43
+ /**
44
+ * Réécrit les liens markdown internes d'une page en slugs navigables.
45
+ *
46
+ * Un lien dont la cible n'est pas indexée est **laissé tel quel** : mieux vaut
47
+ * un lien inerte qu'un slug inventé qui produirait un 404 côté portail.
48
+ *
49
+ * @param markdown - corps de la page (frontmatter déjà retiré).
50
+ * @param options - dossier d'origine + résolution chemin → slug.
51
+ * @returns le markdown avec ses liens internes traduits.
52
+ */
53
+ function rewriteInternalLinks(markdown, options) {
54
+ const { fromDir, toSlug, suffix = ".md" } = options;
55
+ const translate = (href) => {
56
+ const slug = toSlug(resolveRelative(fromDir, href));
57
+ return slug ? `${slug}${suffix}` : null;
58
+ };
59
+ return markdown.replace(MD_LINK, (whole, href, hash = "") => {
60
+ const t = translate(href);
61
+ return t ? `](${t}${hash})` : whole;
62
+ }).replace(JSON_HREF, (whole, href) => {
63
+ const t = translate(href);
64
+ return t ? `"href": "${t}"` : whole;
65
+ });
66
+ }
67
+ //#endregion
68
+ export { rewriteInternalLinks };