@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.
- package/LICENSE +544 -0
- package/README.md +168 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +75 -0
- package/dist/nodefony/config/config.js +71 -0
- package/dist/nodefony/config/defineModuleConfig.js +49 -0
- package/dist/nodefony/controller/DocumentationController.js +102 -0
- package/dist/nodefony/interfaces/IDocumentation.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/DocumentationService.js +421 -0
- package/dist/nodefony/src/docScanner.js +58 -0
- package/dist/nodefony/src/errors/DocumentationError.js +45 -0
- package/dist/nodefony/src/frontmatter.js +63 -0
- package/dist/nodefony/src/linkResolver.js +68 -0
- package/dist/nodefony/src/search.js +120 -0
- package/dist/nodefony/src/slug.js +62 -0
- package/dist/types/index.d.ts +58 -0
- package/dist/types/nodefony/config/config.d.ts +37 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +17 -0
- package/dist/types/nodefony/controller/DocumentationController.d.ts +33 -0
- package/dist/types/nodefony/interfaces/IDocumentation.d.ts +144 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/DocumentationService.d.ts +78 -0
- package/dist/types/nodefony/src/docScanner.d.ts +47 -0
- package/dist/types/nodefony/src/errors/DocumentationError.d.ts +32 -0
- package/dist/types/nodefony/src/frontmatter.d.ts +37 -0
- package/dist/types/nodefony/src/linkResolver.d.ts +55 -0
- package/dist/types/nodefony/src/search.d.ts +64 -0
- package/dist/types/nodefony/src/slug.d.ts +48 -0
- package/docs/architecture.md +647 -0
- package/docs/index.md +369 -0
- 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 };
|