@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,120 @@
|
|
|
1
|
+
//#region nodefony/src/search.ts
|
|
2
|
+
/**
|
|
3
|
+
* Réduit un texte à sa forme comparable : minuscules, accents retirés.
|
|
4
|
+
*
|
|
5
|
+
* « Sécurité » et « securite » doivent trouver la même chose — personne ne tape
|
|
6
|
+
* les accents dans un champ de recherche.
|
|
7
|
+
*/
|
|
8
|
+
function foldText(text) {
|
|
9
|
+
return text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
10
|
+
}
|
|
11
|
+
/** Termes effectivement cherchés : pliés, dédoublonnés, un caractère écarté. */
|
|
12
|
+
function splitSearchTerms(query) {
|
|
13
|
+
return [...new Set(foldText(query).split(/\s+/).filter(Boolean))].filter((t) => t.length > 1);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Prose INDEXABLE d'une page de documentation.
|
|
17
|
+
*
|
|
18
|
+
* Ce qui est retiré ne l'est pas par souci de taille mais de PERTINENCE : un
|
|
19
|
+
* bloc de code fait remonter une page sur `const`, un tableau de compatibilité
|
|
20
|
+
* sur n'importe quel nom de navigateur, et le fil d'Ariane sur le titre de
|
|
21
|
+
* toutes ses voisines. Une recherche qui rend tout ne rend rien.
|
|
22
|
+
*
|
|
23
|
+
* @param markdown - le corps de la page, frontmatter déjà retiré.
|
|
24
|
+
* @returns le texte à indexer, lignes conservées (les extraits s'y resituent).
|
|
25
|
+
*/
|
|
26
|
+
function extractSearchText(markdown) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let inABlock = false;
|
|
29
|
+
for (const brute of markdown.split("\n")) {
|
|
30
|
+
if (/^\s*```/.test(brute)) {
|
|
31
|
+
inABlock = !inABlock;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (inABlock) continue;
|
|
35
|
+
if (/^\s*(📍|!\[|<!--|<[a-z])/.test(brute)) continue;
|
|
36
|
+
if (/^\s*\|[\s|:-]+\|\s*$/.test(brute)) continue;
|
|
37
|
+
out.push(brute.replace(/[ \t]{2,}/g, " "));
|
|
38
|
+
}
|
|
39
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Classe les pages qui portent TOUS les termes, la plus pertinente d'abord.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ Cette fonction est SÉRIALISÉE et exécutée dans un navigateur (voir l'en-tête
|
|
45
|
+
* du fichier) : elle ne doit rien référencer hors de son propre corps.
|
|
46
|
+
*
|
|
47
|
+
* @param docs - le corpus à balayer.
|
|
48
|
+
* @param query - ce que le lecteur a tapé.
|
|
49
|
+
* @param limit - nombre de résultats rendus (le total retenu est dit à part).
|
|
50
|
+
* @returns le classement, plus ce que la recherche a réellement balayé.
|
|
51
|
+
*/
|
|
52
|
+
function searchDocs(docs, query, limit = 20) {
|
|
53
|
+
const fold = (text) => text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
54
|
+
const terms = [...new Set(fold(query).split(/\s+/).filter(Boolean))].filter((t) => t.length > 1);
|
|
55
|
+
if (!terms.length) return {
|
|
56
|
+
query,
|
|
57
|
+
terms,
|
|
58
|
+
scanned: 0,
|
|
59
|
+
matched: 0,
|
|
60
|
+
hits: []
|
|
61
|
+
};
|
|
62
|
+
const hits = [];
|
|
63
|
+
let scanned = 0;
|
|
64
|
+
for (const doc of docs) {
|
|
65
|
+
scanned += 1;
|
|
66
|
+
const foldedTitle = fold(`${doc.title} ${doc.navTitle} ${doc.slug}`);
|
|
67
|
+
const lines = doc.body.split("\n");
|
|
68
|
+
const foldedLines = lines.map(fold);
|
|
69
|
+
const foldedBody = foldedLines.join("\n");
|
|
70
|
+
if (!terms.every((t) => foldedBody.includes(t) || foldedTitle.includes(t))) continue;
|
|
71
|
+
let occurrences = 0;
|
|
72
|
+
for (const t of terms) {
|
|
73
|
+
let i = foldedBody.indexOf(t);
|
|
74
|
+
while (i !== -1) {
|
|
75
|
+
occurrences += 1;
|
|
76
|
+
i = foldedBody.indexOf(t, i + t.length);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const excerpts = [];
|
|
80
|
+
let section;
|
|
81
|
+
for (let i = 0; i < lines.length && excerpts.length < 3; i += 1) {
|
|
82
|
+
const brute = lines[i] ?? "";
|
|
83
|
+
const heading = /^#{2,4}[ \t]+(\S.*)$/.exec(brute);
|
|
84
|
+
if (heading) {
|
|
85
|
+
section = heading[1]?.replace(/[*`_]/g, "").trim();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (/^#\s/.test(brute)) continue;
|
|
89
|
+
if (/^\s*(📍|!\[|\||<!--|```)/.test(brute)) continue;
|
|
90
|
+
const folded = foldedLines[i] ?? "";
|
|
91
|
+
if (!terms.some((t) => folded.includes(t))) continue;
|
|
92
|
+
const lineText = brute.replace(/\[([^\][]*)\]\([^)[]*\)/g, "$1").replace(/^#{1,6}\s*/, "").replace(/[*`_>]/g, "").replace(/\s+/g, " ").trim();
|
|
93
|
+
if (lineText.length < 12) continue;
|
|
94
|
+
excerpts.push({
|
|
95
|
+
...section ? { section } : {},
|
|
96
|
+
text: lineText.length > 220 ? `${lineText.slice(0, 217)}…` : lineText
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const inTitle = terms.filter((t) => foldedTitle.includes(t)).length;
|
|
100
|
+
hits.push({
|
|
101
|
+
slug: doc.slug,
|
|
102
|
+
title: doc.title,
|
|
103
|
+
navTitle: doc.navTitle,
|
|
104
|
+
sectionLabel: doc.sectionLabel,
|
|
105
|
+
excerpts,
|
|
106
|
+
occurrences,
|
|
107
|
+
score: inTitle * 100 + occurrences
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
hits.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
|
111
|
+
return {
|
|
112
|
+
query,
|
|
113
|
+
terms,
|
|
114
|
+
scanned,
|
|
115
|
+
matched: hits.length,
|
|
116
|
+
hits: hits.slice(0, limit)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
export { extractSearchText, foldText, searchDocs, splitSearchTerms };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region nodefony/src/slug.ts
|
|
2
|
+
/**
|
|
3
|
+
* Sécurité et encodage des slugs de documentation.
|
|
4
|
+
*
|
|
5
|
+
* Le data plane LIT des fichiers `.md` sur disque → surface de **traversée de
|
|
6
|
+
* répertoire** (path traversal). Règle de sécurité (cf
|
|
7
|
+
* `feedback_security_rfc_rigor`) :
|
|
8
|
+
*
|
|
9
|
+
* 1. Le slug n'est JAMAIS concaténé brut dans un chemin FS. Le service garde,
|
|
10
|
+
* pour chaque fichier scanné, son chemin absolu RÉEL ; servir une page =
|
|
11
|
+
* retrouver l'entrée par **égalité de slug** dans la liste scannée, puis
|
|
12
|
+
* lire le chemin connu. Le slug est une CLÉ d'allowlist, pas un chemin.
|
|
13
|
+
* 2. {@link isSafeSlug} est une garde **défense-en-profondeur** : on rejette
|
|
14
|
+
* tout slug suspect (segment `..`, séparateur de chemin, octet nul,
|
|
15
|
+
* caractère de contrôle) AVANT même de chercher dans l'allowlist.
|
|
16
|
+
*
|
|
17
|
+
* Schéma de slug (URL-safe, un seul segment de route) :
|
|
18
|
+
* - doc racine `docs/guides/session-storage.md` → `root~guides~session-storage`
|
|
19
|
+
* - doc module `<module>/docs/index.md` → `mod~<module>~index`
|
|
20
|
+
*
|
|
21
|
+
* Le `/` du chemin devient `~` (le slug doit tenir dans UN segment de route
|
|
22
|
+
* `/api/page/{slug}`). On NE reconstruit jamais le chemin depuis le slug.
|
|
23
|
+
*/
|
|
24
|
+
/** Caractères autorisés dans un slug : alphanum + `_` `-` `.` `~`. */
|
|
25
|
+
const SAFE_SLUG = /^[A-Za-z0-9_.~-]+$/;
|
|
26
|
+
/** Longueur maximale d'un slug (borne anti-abus, large mais finie). */
|
|
27
|
+
const MAX_SLUG_LENGTH = 512;
|
|
28
|
+
/**
|
|
29
|
+
* Valide qu'un slug est sûr à manipuler (avant toute recherche/lecture).
|
|
30
|
+
*
|
|
31
|
+
* Rejette : chaîne vide, trop longue, segment `..`, présence de `/` `\` `\0`,
|
|
32
|
+
* tout caractère hors charset autorisé.
|
|
33
|
+
*
|
|
34
|
+
* @param slug - slug brut reçu du client (déjà URL-décodé par le framework).
|
|
35
|
+
* @returns `true` si le slug est sûr, `false` sinon.
|
|
36
|
+
*/
|
|
37
|
+
function isSafeSlug(slug) {
|
|
38
|
+
if (typeof slug !== "string") return false;
|
|
39
|
+
if (slug.length === 0 || slug.length > MAX_SLUG_LENGTH) return false;
|
|
40
|
+
if (slug.includes("\0")) return false;
|
|
41
|
+
if (!SAFE_SLUG.test(slug)) return false;
|
|
42
|
+
if (slug.split("~").some((seg) => seg === "..")) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Construit le slug d'un fichier à partir de sa source et de son chemin
|
|
47
|
+
* relatif (POSIX, sans `.md`). Inverse jamais utilisé (slug = clé, pas chemin).
|
|
48
|
+
*
|
|
49
|
+
* @param source - racine ou module propriétaire.
|
|
50
|
+
* @param relPath - chemin relatif POSIX du `.md` (séparateur `/`).
|
|
51
|
+
* @returns slug URL-safe.
|
|
52
|
+
*/
|
|
53
|
+
function pathToSlug(source, relPath) {
|
|
54
|
+
const clean = relPath.replace(/\\/g, "/").replace(/\.md$/i, "").replace(/\//g, "~");
|
|
55
|
+
return source.kind === "root" ? `root~${clean}` : `mod~${sanitizeSegment(source.module)}~${clean}`;
|
|
56
|
+
}
|
|
57
|
+
/** Normalise un nom de module en segment de slug sûr (`@nodefony/x` → `x`). */
|
|
58
|
+
function sanitizeSegment(name) {
|
|
59
|
+
return name.replace(/^@[^/]+\//, "").replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { isSafeSlug, pathToSlug };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nodefony/documentation — Data plane de documentation transverse de Nodefony.
|
|
3
|
+
*
|
|
4
|
+
* Module **headless** (back pur) : il indexe la documentation co-localisée
|
|
5
|
+
* (`docs/` racine transverse + `<module>/docs/*.md`, ADR-0001), résout les
|
|
6
|
+
* variables dynamiques `{{ }}` côté serveur, et expose le tout sous
|
|
7
|
+
* `/nodefony/documentation/api/*`. Il ne rend AUCUN HTML — le front Studio (et,
|
|
8
|
+
* demain, un générateur de site statique ou le RAG P12) consomme ce data plane.
|
|
9
|
+
*
|
|
10
|
+
* Pourquoi un module dédié (et pas un simple controller Studio) : la doc porte
|
|
11
|
+
* de l'**état** (index scanné, cache TTL, registre de providers `{{ }}`) → un
|
|
12
|
+
* cycle de vie propre, hors hot path request (tout lazy). Cf
|
|
13
|
+
* [[project_doc_portal_faisabilite]].
|
|
14
|
+
*
|
|
15
|
+
* Voir aussi : CLAUDE.md (décisions figées), MEMORY.md (internals IA),
|
|
16
|
+
* README.md (usage humain), docs/ (doc vulgarisée surfacée dans Studio).
|
|
17
|
+
*/
|
|
18
|
+
import { Kernel, Module } from "nodefony";
|
|
19
|
+
import type { DocumentationConfig, DocumentationConfigInput } from "./nodefony/config/config.js";
|
|
20
|
+
import DocumentationService from "./nodefony/service/DocumentationService.js";
|
|
21
|
+
import DocumentationController from "./nodefony/controller/DocumentationController.js";
|
|
22
|
+
declare module "nodefony" {
|
|
23
|
+
interface NodefonyModuleConfig {
|
|
24
|
+
"@nodefony/documentation": DocumentationConfigInput;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
declare class Documentation extends Module<DocumentationConfig> {
|
|
28
|
+
/** Module optionnel : un échec de son boot ne tue jamais le process (résilience Ph.3). */
|
|
29
|
+
static critical: boolean;
|
|
30
|
+
constructor(kernel: Kernel);
|
|
31
|
+
/** JSON Schema de la config documentation → data plane admin (config riche Studio). */
|
|
32
|
+
configSchema(): unknown;
|
|
33
|
+
/**
|
|
34
|
+
* Phase `onRegister` : valide la config (défauts + override `module-documentation`
|
|
35
|
+
* + env) via `defineDocumentationConfig`, puis la ré-assigne à `this.options`
|
|
36
|
+
* AVANT l'instanciation du `@services` (phase `onBoot`). Plante propre avec
|
|
37
|
+
* messages clairs si la config est invalide (convention Zod figée 2026-05-28).
|
|
38
|
+
*/
|
|
39
|
+
onKernelRegister(): Promise<this>;
|
|
40
|
+
/**
|
|
41
|
+
* Phase `onReady` : enregistre les fournisseurs de variables `{{ }}` built-in
|
|
42
|
+
* sur le service (tous les modules sont alors bootés). Sources SÛRES seulement
|
|
43
|
+
* (version, identité git) — jamais de secret ni de chemin FS absolu.
|
|
44
|
+
*/
|
|
45
|
+
onKernelReady(): Promise<this>;
|
|
46
|
+
}
|
|
47
|
+
export default Documentation;
|
|
48
|
+
export { DocumentationService, DocumentationController };
|
|
49
|
+
export { ROOT_GROUPS, ROOT_PAGES, } from "./nodefony/service/DocumentationService.js";
|
|
50
|
+
export { defineDocumentationConfig, documentationConfigJsonSchema, } from "./nodefony/config/defineModuleConfig.js";
|
|
51
|
+
export { documentationConfigSchema, type DocumentationConfig, } from "./nodefony/config/config.js";
|
|
52
|
+
export { parseFrontmatter, metaString, metaList, type Frontmatter, type ParsedDoc, } from "./nodefony/src/frontmatter.js";
|
|
53
|
+
export { scanDocsDir, type ScannedDoc } from "./nodefony/src/docScanner.js";
|
|
54
|
+
export { isSafeSlug, pathToSlug, type DocSource } from "./nodefony/src/slug.js";
|
|
55
|
+
export { searchDocs, extractSearchText, foldText, splitSearchTerms, type SearchableDoc, } from "./nodefony/src/search.js";
|
|
56
|
+
export { rewriteInternalLinks, type RewriteLinksOptions, } from "./nodefony/src/linkResolver.js";
|
|
57
|
+
export { DocumentationError, DocNotFoundError, DocUnsafeSlugError, } from "./nodefony/src/errors/DocumentationError.js";
|
|
58
|
+
export type { DocAudience, DocStatus, DocVarProvider, IDocAudienceInfo, IDocPageRef, IDocSection, IDocTree, IDocPage, IDocumentationService, } from "./nodefony/interfaces/IDocumentation.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const documentationConfigSchema: z.ZodObject<{
|
|
3
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
4
|
+
scan: z.ZodDefault<z.ZodObject<{
|
|
5
|
+
rootDir: z.ZodDefault<z.ZodString>;
|
|
6
|
+
includeModules: z.ZodDefault<z.ZodBoolean>;
|
|
7
|
+
includeInstalled: z.ZodDefault<z.ZodBoolean>;
|
|
8
|
+
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
9
|
+
}, z.core.$strict>>;
|
|
10
|
+
repo: z.ZodDefault<z.ZodObject<{
|
|
11
|
+
url: z.ZodDefault<z.ZodString>;
|
|
12
|
+
branch: z.ZodOptional<z.ZodString>;
|
|
13
|
+
editPathPrefix: z.ZodDefault<z.ZodEnum<{
|
|
14
|
+
blob: "blob";
|
|
15
|
+
edit: "edit";
|
|
16
|
+
tree: "tree";
|
|
17
|
+
}>>;
|
|
18
|
+
}, z.core.$strict>>;
|
|
19
|
+
cache: z.ZodDefault<z.ZodObject<{
|
|
20
|
+
ttlMs: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
}, z.core.$strict>>;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
/** Type de sortie (config normalisée + défauts appliqués). */
|
|
24
|
+
export type DocumentationConfig = z.infer<typeof documentationConfigSchema>;
|
|
25
|
+
/**
|
|
26
|
+
* Type d'ENTRÉE (ce que l'utilisateur écrit dans `use()`, avant application des
|
|
27
|
+
* défauts) — tout y est optionnel. C'est CE type qui augmente le registre
|
|
28
|
+
* `NodefonyModuleConfig`, jamais la sortie : exiger la forme normalisée
|
|
29
|
+
* obligerait l'app à réécrire chaque défaut.
|
|
30
|
+
*/
|
|
31
|
+
export type DocumentationConfigInput = z.input<typeof documentationConfigSchema>;
|
|
32
|
+
/**
|
|
33
|
+
* Défauts du module, matérialisés depuis le schéma (source unique). Toujours
|
|
34
|
+
* valides par construction ; passés au `super(..., config)` du Module class.
|
|
35
|
+
*/
|
|
36
|
+
declare const config: DocumentationConfig;
|
|
37
|
+
export default config;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DocumentationConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Valide une config partielle (défauts du schéma) PUIS applique la surcharge par
|
|
4
|
+
* variables d'environnement (précédence max).
|
|
5
|
+
*
|
|
6
|
+
* @param input - config partielle (depuis `module.options.documentation`)
|
|
7
|
+
* @returns config validée, défauts appliqués, env mergé
|
|
8
|
+
* @throws ZodError si l'input viole le schéma
|
|
9
|
+
*/
|
|
10
|
+
export declare function defineDocumentationConfig(input?: unknown): DocumentationConfig;
|
|
11
|
+
/**
|
|
12
|
+
* JSON Schema introspectable de la config documentation — destiné au panneau de
|
|
13
|
+
* config Studio (`/nodefony/config`). N'inclut PAS la surcharge ENV (appliquée
|
|
14
|
+
* hors schéma, dans le builder).
|
|
15
|
+
*/
|
|
16
|
+
export declare function documentationConfigJsonSchema(): unknown;
|
|
17
|
+
export { documentationConfigSchema } from "./config.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Controller } from "@nodefony/framework";
|
|
2
|
+
import { Context } from "@nodefony/http";
|
|
3
|
+
/**
|
|
4
|
+
* Data plane HTTP de la documentation Nodefony.
|
|
5
|
+
*
|
|
6
|
+
* Expose l'index transverse et le contenu des pages sous
|
|
7
|
+
* `/nodefony/documentation/api/*` (convention figée : un module admin sert
|
|
8
|
+
* toujours `/nodefony/<module>/api/*`, jamais une route mono-segment).
|
|
9
|
+
*
|
|
10
|
+
* **Mince par design** : toute la logique (scan, cache, allowlist, résolution
|
|
11
|
+
* `{{ }}`) vit dans `DocumentationService` (singleton stateful). Le controller
|
|
12
|
+
* est réinstancié par requête → il ne porte aucun état, il délègue.
|
|
13
|
+
*
|
|
14
|
+
* Sécurité (Zero Trust) : un slug invalide/inconnu renvoie un message
|
|
15
|
+
* **générique** au client ; le détail (slug, raison) est loggé côté serveur.
|
|
16
|
+
* La garde anti-traversée est dans le service ({@link isSafeSlug}).
|
|
17
|
+
*/
|
|
18
|
+
declare class DocumentationController extends Controller {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(context: Context);
|
|
21
|
+
/** Index transverse : sections → pages, taguées par audience. */
|
|
22
|
+
tree(): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
23
|
+
/**
|
|
24
|
+
* Cherche dans le corpus — titres ET corps — et rend des extraits situés.
|
|
25
|
+
*
|
|
26
|
+
* Filtrer l'arbre ne répondait qu'à « quelle page s'appelle ainsi ? » ; la
|
|
27
|
+
* question posée est « où est-ce expliqué ? ».
|
|
28
|
+
*/
|
|
29
|
+
search(q?: string): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
30
|
+
/** Contenu d'une page + variables `{{ }}` résolues côté serveur. */
|
|
31
|
+
page(slug: string): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
32
|
+
}
|
|
33
|
+
export default DocumentationController;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interfaces publiques du data plane de documentation.
|
|
3
|
+
*
|
|
4
|
+
* Contrat exposé par `/nodefony/documentation/api/*` et consommé par le front
|
|
5
|
+
* Studio (et, demain, par un générateur de site statique ou le RAG P12). Le
|
|
6
|
+
* module est **headless** : il produit ces shapes, il ne rend aucun HTML.
|
|
7
|
+
*/
|
|
8
|
+
/** Persona métier ciblée par une page (RBAC P6 — aujourd'hui filtre de vue). */
|
|
9
|
+
export type DocAudience = "developer" | "devops" | "supervisor" | "admin";
|
|
10
|
+
/** Statut de maturité d'une page (issu du frontmatter `status`). */
|
|
11
|
+
export type DocStatus = "stable" | "draft" | "temporary" | "experimental" | "deprecated";
|
|
12
|
+
/** Une persona décrite dans l'index (clé + libellé + description courte). */
|
|
13
|
+
export interface IDocAudienceInfo {
|
|
14
|
+
key: DocAudience;
|
|
15
|
+
label: string;
|
|
16
|
+
desc: string;
|
|
17
|
+
}
|
|
18
|
+
/** Entrée d'une page dans l'arbre (métadonnées seules, sans le markdown). */
|
|
19
|
+
export interface IDocPageRef {
|
|
20
|
+
/** Identifiant URL-safe unique (sert de clé d'allowlist anti-traversée). */
|
|
21
|
+
slug: string;
|
|
22
|
+
/** Titre lisible (frontmatter `title`, sinon dérivé du nom de fichier). */
|
|
23
|
+
title: string;
|
|
24
|
+
/**
|
|
25
|
+
* Libellé COURT pour la navigation (frontmatter `navTitle`, repli sur
|
|
26
|
+
* {@link title}). Le menu est une colonne étroite, le titre est écrit pour être
|
|
27
|
+
* lu en tête d'article : sans ce champ, l'arbre affiche des phrases et la
|
|
28
|
+
* recherche ne trouve pas le mot qu'on VOIT à l'écran.
|
|
29
|
+
*/
|
|
30
|
+
navTitle: string;
|
|
31
|
+
/** Personas autorisées (frontmatter `audience`). Vide = toutes. */
|
|
32
|
+
audience: DocAudience[];
|
|
33
|
+
/** Version de la page (frontmatter `version`, ou `"doc"`). */
|
|
34
|
+
version?: string;
|
|
35
|
+
/** Statut de maturité (frontmatter `status`). */
|
|
36
|
+
status?: DocStatus;
|
|
37
|
+
/** `true` si la page est annoncée mais pas encore rédigée. */
|
|
38
|
+
wip?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* `true` si la page est le **hub** de sa section (`index.md`) : son point
|
|
41
|
+
* d'entrée, à présenter en premier et non comme une page parmi les autres.
|
|
42
|
+
*/
|
|
43
|
+
isHub?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Une section de l'index transverse : un groupe ordonné de pages. */
|
|
46
|
+
export interface IDocSection {
|
|
47
|
+
/** Identifiant stable de la section (URL-safe). */
|
|
48
|
+
id: string;
|
|
49
|
+
/** Libellé affiché. */
|
|
50
|
+
label: string;
|
|
51
|
+
/** Module propriétaire (`@nodefony/x`) si la section vient d'un module. */
|
|
52
|
+
module?: string;
|
|
53
|
+
/** Pages de la section. */
|
|
54
|
+
pages: IDocPageRef[];
|
|
55
|
+
}
|
|
56
|
+
/** Index transverse complet renvoyé par `GET …/api/tree`. */
|
|
57
|
+
export interface IDocTree {
|
|
58
|
+
/** Date ISO de génération de l'index (cache invalidé après TTL). */
|
|
59
|
+
generatedAt: string;
|
|
60
|
+
/** Personas connues (pour le sélecteur de vue / RBAC). */
|
|
61
|
+
audiences: IDocAudienceInfo[];
|
|
62
|
+
/** Sections de l'index. */
|
|
63
|
+
sections: IDocSection[];
|
|
64
|
+
}
|
|
65
|
+
/** Contenu complet d'une page renvoyé par `GET …/api/page/{slug}`. */
|
|
66
|
+
export interface IDocPage {
|
|
67
|
+
slug: string;
|
|
68
|
+
title: string;
|
|
69
|
+
version?: string;
|
|
70
|
+
status?: DocStatus;
|
|
71
|
+
/** Date de dernière modif (frontmatter `updated` ou dernier commit git). */
|
|
72
|
+
updated?: string;
|
|
73
|
+
/** Chemin source relatif au repo (pour traçabilité). */
|
|
74
|
+
source?: string;
|
|
75
|
+
/** URL « Modifier sur GitHub » assemblée serveur (jamais de chemin FS). */
|
|
76
|
+
sourceUrl?: string;
|
|
77
|
+
/** Markdown SANS le bloc frontmatter, variables `{{ }}` résolues. */
|
|
78
|
+
markdown: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Fournisseur d'une variable dynamique `{{ name }}` résolue côté serveur.
|
|
82
|
+
*
|
|
83
|
+
* Doit retourner une valeur **sûre** (publique, dérivée) : version, compteur,
|
|
84
|
+
* nom de symbole — JAMAIS un secret ni un chemin FS absolu. Synchrone par
|
|
85
|
+
* design (résolution dans le hot path froid de lecture d'une page).
|
|
86
|
+
*/
|
|
87
|
+
export type DocVarProvider = () => string;
|
|
88
|
+
/** Service public du module documentation (consommé par le controller). */
|
|
89
|
+
export interface IDocumentationService {
|
|
90
|
+
/** Construit (ou sert depuis le cache) l'index transverse des docs. */
|
|
91
|
+
getTree(): Promise<IDocTree>;
|
|
92
|
+
/**
|
|
93
|
+
* Charge une page par slug (validé contre l'allowlist du scan), résout son
|
|
94
|
+
* frontmatter et ses variables `{{ }}`.
|
|
95
|
+
*
|
|
96
|
+
* @throws DocNotFoundError si le slug est inconnu
|
|
97
|
+
* @throws DocUnsafeSlugError si le slug est rejeté par la garde de sécurité
|
|
98
|
+
*/
|
|
99
|
+
getPage(slug: string): Promise<IDocPage>;
|
|
100
|
+
/**
|
|
101
|
+
* Cherche dans les titres ET le corps des pages, et rend des extraits situés.
|
|
102
|
+
*
|
|
103
|
+
* @param query - la saisie brute ; les termes d'un caractère sont ignorés.
|
|
104
|
+
* @param limit - nombre maximal de pages rendues (défaut 20).
|
|
105
|
+
*/
|
|
106
|
+
search(query: string, limit?: number): Promise<IDocSearchResult>;
|
|
107
|
+
/** Enregistre un fournisseur de variable `{{ name }}` (résolution serveur). */
|
|
108
|
+
registerVar(name: string, provider: DocVarProvider): void;
|
|
109
|
+
/** Invalide le cache de l'index (force un rescan au prochain `getTree`). */
|
|
110
|
+
invalidate(): void;
|
|
111
|
+
}
|
|
112
|
+
/** Un extrait de texte où les termes cherchés apparaissent. */
|
|
113
|
+
export interface IDocSearchExcerpt {
|
|
114
|
+
/** Le titre de section (`##`) sous lequel l'extrait a été trouvé, s'il y en a un. */
|
|
115
|
+
section?: string;
|
|
116
|
+
/** Le texte de l'extrait, borné — les termes y sont encadrés par `\u0000`. */
|
|
117
|
+
text: string;
|
|
118
|
+
}
|
|
119
|
+
/** Une page retenue par la recherche. */
|
|
120
|
+
export interface IDocSearchHit {
|
|
121
|
+
slug: string;
|
|
122
|
+
title: string;
|
|
123
|
+
navTitle: string;
|
|
124
|
+
/** Libellé de la section d'arbre qui porte la page (« Guides », « Cœur »…). */
|
|
125
|
+
sectionLabel: string;
|
|
126
|
+
/** Extraits, dans l'ordre du document — bornés. */
|
|
127
|
+
excerpts: IDocSearchExcerpt[];
|
|
128
|
+
/** Occurrences TOTALES dans la page ; les extraits, eux, sont bornés. */
|
|
129
|
+
occurrences: number;
|
|
130
|
+
/** Pertinence décroissante : un terme dans le titre pèse plus que dans le corps. */
|
|
131
|
+
score: number;
|
|
132
|
+
}
|
|
133
|
+
/** Réponse de `/documentation/api/search`. */
|
|
134
|
+
export interface IDocSearchResult {
|
|
135
|
+
/** La requête telle que reçue. */
|
|
136
|
+
query: string;
|
|
137
|
+
/** Les termes effectivement cherchés (pliés, vides retirés). */
|
|
138
|
+
terms: string[];
|
|
139
|
+
/** Pages lues pour répondre — dit ce que la recherche a réellement balayé. */
|
|
140
|
+
scanned: number;
|
|
141
|
+
/** Pages retenues AVANT bornage — un total de 40 avec 20 rendus se dit. */
|
|
142
|
+
matched: number;
|
|
143
|
+
hits: IDocSearchHit[];
|
|
144
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel des interfaces publiques de `@nodefony/documentation`.
|
|
3
|
+
* Réexporté par `index.ts` racine via `export type { ... }`.
|
|
4
|
+
*/
|
|
5
|
+
export type { DocAudience, DocStatus, DocVarProvider, IDocAudienceInfo, IDocPageRef, IDocSection, IDocTree, IDocPage, IDocumentationService, } from "./IDocumentation.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Module, Service, type Message, type Msgid, type Pci, type Severity } from "nodefony";
|
|
2
|
+
import type { DocumentationConfig } from "../config/config.js";
|
|
3
|
+
import type { DocVarProvider, IDocPage, IDocSearchResult, IDocTree } from "../interfaces/IDocumentation.js";
|
|
4
|
+
/**
|
|
5
|
+
* Les groupes racine publiés, **dans l'ordre du menu**, avec leur libellé.
|
|
6
|
+
*
|
|
7
|
+
* L'ordre est PÉDAGOGIQUE, jamais alphabétique, et il part du geste : on fait une
|
|
8
|
+
* première fois en étant guidé (tutoriels), on refait seul sur un besoin précis
|
|
9
|
+
* (guides), puis on comprend ce qui se passait dessous (architecture). Un tri
|
|
10
|
+
* alphabétique mettait « ADR » en tête et « Tutoriels » en cinquième position —
|
|
11
|
+
* l'inverse exact du chemin de lecture.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **C'est la SEULE définition de ce périmètre.** Le générateur du site public
|
|
14
|
+
* (`scripts/build-docs-site.mjs`) l'importe depuis le `dist` de ce module — il en
|
|
15
|
+
* importait déjà les briques de scan — au lieu d'en tenir une copie. Une copie a
|
|
16
|
+
* existé ici, sous le prétexte d'une frontière de paquets que le script
|
|
17
|
+
* franchissait pourtant déjà ; elle avait commencé à diverger. N'en réintroduire
|
|
18
|
+
* aucune : le portail et le site doivent publier les MÊMES sections, sinon un
|
|
19
|
+
* lecteur trouve dans l'un ce que l'autre lui cache.
|
|
20
|
+
*/
|
|
21
|
+
export declare const ROOT_GROUPS: ReadonlyArray<{
|
|
22
|
+
group: string;
|
|
23
|
+
label: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Pages de `docs/` (à la racine, hors sous-dossier) publiées dans le menu, dans
|
|
27
|
+
* cet ordre. Le reste de ce dossier est du PILOTAGE — carte des phases, README du
|
|
28
|
+
* corpus, essai sur l'outillage : utile au mainteneur du framework, illisible
|
|
29
|
+
* pour qui construit une application.
|
|
30
|
+
*
|
|
31
|
+
* Comme {@link ROOT_GROUPS}, c'est la seule définition : le site public l'importe.
|
|
32
|
+
*/
|
|
33
|
+
export declare const ROOT_PAGES: ReadonlyArray<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Service de documentation Nodefony — **headless** : produit l'index transverse
|
|
36
|
+
* et le contenu résolu des pages, sans rendre aucun HTML (le front Studio, un
|
|
37
|
+
* générateur statique ou le RAG le consomment).
|
|
38
|
+
*
|
|
39
|
+
* Sources scannées (config `scan`) : le dossier `docs/` racine (transverse) +
|
|
40
|
+
* les `<module>/docs/*.md` co-localisés (ADR-0001) si `includeModules`.
|
|
41
|
+
*
|
|
42
|
+
* Perf/mémoire (règle absolue) : tout est lazy. L'index est construit au 1ᵉʳ
|
|
43
|
+
* accès et caché avec un TTL (`cache.ttlMs`) — le scan FS n'est PAS refait à
|
|
44
|
+
* chaque requête. Le registre de variables `{{ }}` est alloué au 1ᵉʳ
|
|
45
|
+
* `registerVar`. 0 alloc par requête hors la lecture froide d'une page (chemin
|
|
46
|
+
* admin, pas le hot path applicatif).
|
|
47
|
+
*/
|
|
48
|
+
declare class DocumentationService extends Service {
|
|
49
|
+
#private;
|
|
50
|
+
module: Module;
|
|
51
|
+
constructor(module: Module);
|
|
52
|
+
log(pci: Pci, severity?: Severity, msgid?: Msgid, msg?: Message): import("nodefony").Pdu;
|
|
53
|
+
registerVar(name: string, provider: DocVarProvider): void;
|
|
54
|
+
invalidate(): void;
|
|
55
|
+
getTree(): Promise<IDocTree>;
|
|
56
|
+
/**
|
|
57
|
+
* Cherche `query` dans le corpus — titres ET corps — et rend des EXTRAITS.
|
|
58
|
+
*
|
|
59
|
+
* Pourquoi une recherche à part, et pas un filtre de l'arbre : filtrer le menu
|
|
60
|
+
* ne répond qu'à « quelle page s'appelle ainsi ? ». La question réelle est
|
|
61
|
+
* « où est-ce expliqué ? », dont la réponse est dans le CORPS des pages. Une
|
|
62
|
+
* recherche qui ne lit que les titres laisse croire qu'un sujet n'est pas
|
|
63
|
+
* documenté alors qu'il l'est, dans une page dont le nom ne le dit pas.
|
|
64
|
+
*
|
|
65
|
+
* Tout est borné : le nombre de pages rendues, le nombre d'extraits par page,
|
|
66
|
+
* la longueur d'un extrait. Le total AVANT bornage est rendu à part
|
|
67
|
+
* (`matched`), pour que « 20 résultats » ne se lise pas comme « il n'y en a
|
|
68
|
+
* que 20 ».
|
|
69
|
+
*
|
|
70
|
+
* @param query - la saisie brute de l'utilisateur.
|
|
71
|
+
* @param limit - nombre maximal de pages rendues (défaut 20).
|
|
72
|
+
* @returns les pages retenues, leurs extraits, et ce qui a été balayé.
|
|
73
|
+
*/
|
|
74
|
+
search(query: string, limit?: number): Promise<IDocSearchResult>;
|
|
75
|
+
getPage(slug: string): Promise<IDocPage>;
|
|
76
|
+
}
|
|
77
|
+
export default DocumentationService;
|
|
78
|
+
export type { DocumentationConfig };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Frontmatter } from "./frontmatter.js";
|
|
2
|
+
import { type DocSource } from "./slug.js";
|
|
3
|
+
/**
|
|
4
|
+
* Un fichier `.md` découvert par le scan, avec son chemin RÉEL et ses
|
|
5
|
+
* métadonnées. Le `absPath` est la seule source de vérité pour lire le fichier
|
|
6
|
+
* (jamais reconstruit depuis le slug → 0 traversée de répertoire).
|
|
7
|
+
*/
|
|
8
|
+
export interface ScannedDoc {
|
|
9
|
+
/** Clé d'allowlist URL-safe (cf {@link pathToSlug}). */
|
|
10
|
+
slug: string;
|
|
11
|
+
/** Chemin relatif POSIX au dossier de base scanné. */
|
|
12
|
+
relPath: string;
|
|
13
|
+
/** Chemin absolu RÉEL du fichier (pour la lecture). */
|
|
14
|
+
absPath: string;
|
|
15
|
+
/** Origine (racine du projet ou module). */
|
|
16
|
+
source: DocSource;
|
|
17
|
+
/** Chemin du dossier parent (POSIX) — sert à regrouper en sections. */
|
|
18
|
+
group: string;
|
|
19
|
+
/** Frontmatter parsé (title/audience/section/version/status/updated/source). */
|
|
20
|
+
meta: Frontmatter;
|
|
21
|
+
/** Titre résolu (frontmatter `title`, sinon nom de fichier humanisé). */
|
|
22
|
+
title: string;
|
|
23
|
+
/**
|
|
24
|
+
* Libellé de NAVIGATION — court, destiné aux menus et fils d'Ariane.
|
|
25
|
+
*
|
|
26
|
+
* Un titre d'article porte le sens et souvent un sous-titre ; la colonne de
|
|
27
|
+
* navigation, elle, fait quelques centimètres et ne tronque rien. Les deux
|
|
28
|
+
* usages n'ont donc pas la même contrainte, d'où deux champs.
|
|
29
|
+
*
|
|
30
|
+
* Résolu depuis le frontmatter `navTitle`, avec repli sur {@link title} :
|
|
31
|
+
* une page qui n'en déclare pas garde exactement le comportement d'avant.
|
|
32
|
+
*/
|
|
33
|
+
navTitle: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Scanne récursivement un dossier de docs et retourne les fichiers `.md`
|
|
37
|
+
* trouvés, frontmatter lu, triés par chemin relatif.
|
|
38
|
+
*
|
|
39
|
+
* Best-effort : un dossier absent (`ENOENT`) renvoie `[]` (pas d'erreur) ; un
|
|
40
|
+
* fichier illisible garde son titre humanisé (frontmatter ignoré).
|
|
41
|
+
*
|
|
42
|
+
* @param baseDir - dossier racine à scanner (absolu).
|
|
43
|
+
* @param source - origine taguée sur chaque doc (racine ou module).
|
|
44
|
+
* @param exclude - noms de segments de chemin à ignorer.
|
|
45
|
+
* @returns liste des docs trouvés (vide si dossier absent).
|
|
46
|
+
*/
|
|
47
|
+
export declare function scanDocsDir(baseDir: string, source: DocSource, exclude?: readonly string[]): Promise<ScannedDoc[]>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { nodefonyError } from "nodefony";
|
|
2
|
+
/**
|
|
3
|
+
* Erreur typée du module `@nodefony/documentation`.
|
|
4
|
+
*
|
|
5
|
+
* Étend `nodefonyError` (le wrapper d'erreur du core, renommé pour ne pas
|
|
6
|
+
* entrer en collision avec `globalThis.Error`). Porte un `code` machine stable
|
|
7
|
+
* pour que le data plane distingue les cas sans parser le message (Zero Trust :
|
|
8
|
+
* le message détaillé reste serveur, le client ne voit qu'un code + un message
|
|
9
|
+
* générique).
|
|
10
|
+
*/
|
|
11
|
+
export declare class DocumentationError extends nodefonyError {
|
|
12
|
+
/**
|
|
13
|
+
* Code machine stable (ex. `DOC_NOT_FOUND`, `DOC_UNSAFE_SLUG`).
|
|
14
|
+
*
|
|
15
|
+
* Nommé `docCode` (pas `code`) : `nodefonyError` réserve déjà `code?: number`
|
|
16
|
+
* (statut HTTP). On ne shadow pas un champ numérique du parent par un string.
|
|
17
|
+
*/
|
|
18
|
+
readonly docCode: string;
|
|
19
|
+
constructor(message: string, docCode?: string);
|
|
20
|
+
}
|
|
21
|
+
/** Slug demandé absent de l'allowlist construite par le scan (404 logique). */
|
|
22
|
+
export declare class DocNotFoundError extends DocumentationError {
|
|
23
|
+
constructor(slug: string);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Slug rejeté car potentiellement dangereux (traversée de répertoire, segment
|
|
27
|
+
* `..`, caractère hors charset). Sécurité : on ne touche JAMAIS au FS avec un
|
|
28
|
+
* tel slug.
|
|
29
|
+
*/
|
|
30
|
+
export declare class DocUnsafeSlugError extends DocumentationError {
|
|
31
|
+
constructor(slug: string);
|
|
32
|
+
}
|