@hgdc/docs 0.2.0 → 0.3.0
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/docs.schema.json +21 -0
- package/package.json +3 -1
- package/src/build/auto-import.ts +5 -24
- package/src/core/components.ts +166 -0
- package/src/core/config.ts +42 -0
- package/src/core/feedback-api.ts +17 -0
- package/src/core/validate.ts +276 -0
- package/src/integration.ts +29 -15
- package/src/overrides/Footer.astro +154 -0
- package/src/overrides/Head.astro +14 -14
- package/src/overrides/PageTitle.astro +95 -95
- package/src/overrides/Sidebar.astro +60 -60
- package/src/overrides/SocialIcons.astro +59 -0
- package/template/.astro/content-assets.mjs +4 -1
- package/template/.astro/content-modules.mjs +15 -15
- package/template/.build/.well-known/llms.txt +15 -15
- package/template/.build/404.html +5 -5
- package/template/.build/_astro/Search.astro_astro_type_script_index_0_lang.g9hoxVJg.js +1 -0
- package/template/.build/_astro/common.CQYUsl_5.css +3 -0
- package/template/.build/_astro/{ui-core.CrG20ya_.js → ui-core.Clh5jUUO.js} +1 -1
- package/template/.build/_hgdc/corpus.json +1 -1
- package/template/.build/demarrer/premiere-doc/index.html +15 -15
- package/template/.build/demarrer/premiere-doc.md +3 -3
- package/template/.build/explications/donnees/index.html +10 -10
- package/template/.build/explications/fonctionnement/index.html +10 -11
- package/template/.build/guides/agents/index.html +11 -11
- package/template/.build/guides/agents.md +1 -1
- package/template/.build/guides/assistant/index.html +11 -11
- package/template/.build/guides/composants/index.html +19 -19
- package/template/.build/guides/composants.md +10 -8
- package/template/.build/guides/ecrire/index.html +12 -13
- package/template/.build/guides/elements/index.html +1 -0
- package/template/.build/guides/navigation/index.html +12 -13
- package/template/.build/guides/publier/index.html +14 -23
- package/template/.build/guides/publier.md +1 -1
- package/template/.build/guides/theme/index.html +12 -13
- package/template/.build/index.html +11 -12
- package/template/.build/index.md +9 -9
- package/template/.build/llms-full.txt +59 -63
- package/template/.build/llms.txt +15 -15
- package/template/.build/pagefind/fragment/fr-ch_148313b.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_35f556a.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_48ca71e.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_c589c2a.pf_fragment +0 -0
- package/template/.build/pagefind/index/fr-ch_c5ced52.pf_index +0 -0
- package/template/.build/pagefind/pagefind-entry.json +1 -1
- package/template/.build/pagefind/pagefind.fr-ch_8a246aa922758.pf_meta +0 -0
- package/template/.build/reference/api-assistant/index.html +12 -12
- package/template/.build/reference/api-assistant.md +2 -12
- package/template/.build/reference/cli/index.html +10 -10
- package/template/.build/reference/docs-json/index.html +35 -11
- package/template/.build/reference/docs-json.md +18 -14
- package/template/.build/reference/mcp/index.html +11 -11
- package/template/node_modules/.vite/deps/_metadata.json +8 -8
- package/template/.build/_astro/Search.astro_astro_type_script_index_0_lang.Cn3-p701.js +0 -1
- package/template/.build/_astro/common.C-MpX45O.css +0 -3
- package/template/.build/pagefind/fragment/fr-ch_68e734b.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_cfa4336.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_e02aa49.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_e254a54.pf_fragment +0 -0
- package/template/.build/pagefind/index/fr-ch_a71411b.pf_index +0 -0
- package/template/.build/pagefind/pagefind.fr-ch_2f3ea8fccf961.pf_meta +0 -0
package/docs.schema.json
CHANGED
|
@@ -248,6 +248,27 @@
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
},
|
|
251
|
+
"redirects": {
|
|
252
|
+
"maxItems": 500,
|
|
253
|
+
"type": "array",
|
|
254
|
+
"items": {
|
|
255
|
+
"type": "object",
|
|
256
|
+
"properties": {
|
|
257
|
+
"source": {
|
|
258
|
+
"type": "string",
|
|
259
|
+
"pattern": "^\\/(?!\\/)[A-Za-z0-9._~\\-/%]*$"
|
|
260
|
+
},
|
|
261
|
+
"destination": {
|
|
262
|
+
"type": "string",
|
|
263
|
+
"minLength": 1
|
|
264
|
+
},
|
|
265
|
+
"permanent": {
|
|
266
|
+
"type": "boolean"
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
"required": ["source", "destination"]
|
|
270
|
+
}
|
|
271
|
+
},
|
|
251
272
|
"assistant": {
|
|
252
273
|
"type": "object",
|
|
253
274
|
"properties": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/docs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Modèle de site de HGDC Docs : documentation MDX (docs.json) construite avec Astro Starlight, recherche, versions, llms.txt et assistant.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"@astrojs/starlight": "0.42.6",
|
|
32
32
|
"@fontsource-variable/geist": "5.3.0",
|
|
33
33
|
"@fontsource-variable/inter": "5.3.0",
|
|
34
|
+
"@mdx-js/mdx": "3.1.1",
|
|
34
35
|
"astro": "7.3.8",
|
|
35
36
|
"github-slugger": "2.0.0",
|
|
36
37
|
"hast-util-select": "6.0.4",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"remark-stringify": "11.0.0",
|
|
42
43
|
"unified": "11.0.5",
|
|
43
44
|
"vfile": "6.0.3",
|
|
45
|
+
"yaml": "2.9.1",
|
|
44
46
|
"zod": "4.6.5"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
package/src/build/auto-import.ts
CHANGED
|
@@ -1,30 +1,7 @@
|
|
|
1
1
|
import type { Root } from "mdast"
|
|
2
2
|
import type { VFile } from "vfile"
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
* Composants disponibles dans chaque page MDX sans `import`, comme chez Mintlify
|
|
6
|
-
* (`<Note>`, `<Steps>`, `<Card>`…). Le plugin ajoute en tête du fichier l'import des
|
|
7
|
-
* composants que la page n'importe pas déjà elle-même.
|
|
8
|
-
*/
|
|
9
|
-
export const MDX_COMPONENTS = [
|
|
10
|
-
"Accordion",
|
|
11
|
-
"AccordionGroup",
|
|
12
|
-
"Card",
|
|
13
|
-
"CardGroup",
|
|
14
|
-
"Check",
|
|
15
|
-
"CodeGroup",
|
|
16
|
-
"Columns",
|
|
17
|
-
"Danger",
|
|
18
|
-
"Frame",
|
|
19
|
-
"Info",
|
|
20
|
-
"Note",
|
|
21
|
-
"Step",
|
|
22
|
-
"Steps",
|
|
23
|
-
"Tab",
|
|
24
|
-
"Tabs",
|
|
25
|
-
"Tip",
|
|
26
|
-
"Warning",
|
|
27
|
-
] as const
|
|
4
|
+
import { MDX_COMPONENTS } from "../core/components.ts"
|
|
28
5
|
|
|
29
6
|
const SOURCE = "@hgdc/docs/components"
|
|
30
7
|
|
|
@@ -64,6 +41,10 @@ function importNode(name: string): EsmNode {
|
|
|
64
41
|
}
|
|
65
42
|
}
|
|
66
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Ajoute en tête de chaque page MDX l'import des composants (`core/components.ts`) que la
|
|
46
|
+
* page n'importe pas déjà elle-même : `<Note>`, `<Steps>`… s'écrivent sans `import`.
|
|
47
|
+
*/
|
|
67
48
|
export function remarkAutoImport() {
|
|
68
49
|
return (tree: Root, file: VFile) => {
|
|
69
50
|
if (!String(file.path ?? file.history.at(-1) ?? "").endsWith(".mdx")) return
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composants disponibles dans chaque page MDX sans `import`, comme chez Mintlify
|
|
3
|
+
* (`<Note>`, `<Steps>`, `<Card>`…). Lus par le build (`build/auto-import.ts`) et par
|
|
4
|
+
* l'éditeur de la console (validation, insertion par « / » ou « < », mode visuel).
|
|
5
|
+
* Les props décrites ici sont celles des fichiers `src/components/*.astro`.
|
|
6
|
+
*/
|
|
7
|
+
export const MDX_COMPONENTS = [
|
|
8
|
+
"Accordion",
|
|
9
|
+
"AccordionGroup",
|
|
10
|
+
"Card",
|
|
11
|
+
"CardGroup",
|
|
12
|
+
"Check",
|
|
13
|
+
"CodeGroup",
|
|
14
|
+
"Columns",
|
|
15
|
+
"Danger",
|
|
16
|
+
"Frame",
|
|
17
|
+
"Info",
|
|
18
|
+
"Note",
|
|
19
|
+
"Step",
|
|
20
|
+
"Steps",
|
|
21
|
+
"Tab",
|
|
22
|
+
"Tabs",
|
|
23
|
+
"Tip",
|
|
24
|
+
"Warning",
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
export type MdxComponent = (typeof MDX_COMPONENTS)[number]
|
|
28
|
+
|
|
29
|
+
export function isMdxComponent(name: string): name is MdxComponent {
|
|
30
|
+
return (MDX_COMPONENTS as ReadonlyArray<string>).includes(name)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ComponentProp = {
|
|
34
|
+
name: string
|
|
35
|
+
label: string
|
|
36
|
+
type: "string" | "number" | "boolean"
|
|
37
|
+
required?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Props de chaque composant, pour les formulaires du mode visuel. */
|
|
41
|
+
export const COMPONENT_PROPS: Record<MdxComponent, ReadonlyArray<ComponentProp>> = {
|
|
42
|
+
Accordion: [
|
|
43
|
+
{ name: "title", label: "Titre", type: "string", required: true },
|
|
44
|
+
{ name: "defaultOpen", label: "Ouvert au départ", type: "boolean" },
|
|
45
|
+
],
|
|
46
|
+
AccordionGroup: [],
|
|
47
|
+
Card: [
|
|
48
|
+
{ name: "title", label: "Titre", type: "string", required: true },
|
|
49
|
+
{ name: "href", label: "Lien", type: "string" },
|
|
50
|
+
],
|
|
51
|
+
CardGroup: [{ name: "cols", label: "Colonnes", type: "number" }],
|
|
52
|
+
Check: [{ name: "title", label: "Titre", type: "string" }],
|
|
53
|
+
CodeGroup: [],
|
|
54
|
+
Columns: [{ name: "cols", label: "Colonnes", type: "number" }],
|
|
55
|
+
Danger: [{ name: "title", label: "Titre", type: "string" }],
|
|
56
|
+
Frame: [{ name: "caption", label: "Légende", type: "string" }],
|
|
57
|
+
Info: [{ name: "title", label: "Titre", type: "string" }],
|
|
58
|
+
Note: [{ name: "title", label: "Titre", type: "string" }],
|
|
59
|
+
Step: [{ name: "title", label: "Titre", type: "string", required: true }],
|
|
60
|
+
Steps: [],
|
|
61
|
+
Tab: [{ name: "title", label: "Titre", type: "string", required: true }],
|
|
62
|
+
Tabs: [{ name: "syncKey", label: "Clé de synchronisation", type: "string" }],
|
|
63
|
+
Tip: [{ name: "title", label: "Titre", type: "string" }],
|
|
64
|
+
Warning: [{ name: "title", label: "Titre", type: "string" }],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type BlockCategory = "Encadrés" | "Mise en page" | "Contenu"
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Blocs proposés à l'insertion, dans l'ordre du menu. `snippet` est le MDX inséré ; `$|`
|
|
71
|
+
* marque l'endroit où placer le curseur. Plusieurs blocs peuvent produire le même
|
|
72
|
+
* composant (colonnes à 2, 3 ou 4).
|
|
73
|
+
*/
|
|
74
|
+
export type Block = {
|
|
75
|
+
id: string
|
|
76
|
+
label: string
|
|
77
|
+
description: string
|
|
78
|
+
category: BlockCategory
|
|
79
|
+
keywords: ReadonlyArray<string>
|
|
80
|
+
snippet: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const callout = (name: string, label: string, description: string, keywords: Array<string>): Block => ({
|
|
84
|
+
id: name.toLowerCase(),
|
|
85
|
+
label,
|
|
86
|
+
description,
|
|
87
|
+
category: "Encadrés",
|
|
88
|
+
keywords: [name.toLowerCase(), ...keywords],
|
|
89
|
+
snippet: `<${name}>\n$|\n</${name}>`,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const columns = (cols: number): Block => ({
|
|
93
|
+
id: `columns-${cols}`,
|
|
94
|
+
label: `${cols} colonnes`,
|
|
95
|
+
description: `Cartes côte à côte, sur ${cols} colonnes.`,
|
|
96
|
+
category: "Mise en page",
|
|
97
|
+
keywords: ["colonnes", "columns", "grille", String(cols)],
|
|
98
|
+
snippet: `<Columns cols={${cols}}>\n${Array.from({ length: cols }, (_, i) => ` <Card title="Carte ${i + 1}">${i === 0 ? "$|" : ""}</Card>`).join("\n")}\n</Columns>`,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
export const BLOCKS: ReadonlyArray<Block> = [
|
|
102
|
+
callout("Note", "Note", "Une information utile à côté du texte.", ["information", "remarque"]),
|
|
103
|
+
callout("Tip", "Conseil", "Une astuce qui fait gagner du temps.", ["astuce", "conseil"]),
|
|
104
|
+
callout("Info", "Info", "Un contexte ou une précision.", ["information"]),
|
|
105
|
+
callout("Warning", "Attention", "Un point à ne pas manquer.", ["attention", "avertissement"]),
|
|
106
|
+
callout("Danger", "Danger", "Une action risquée ou irréversible.", ["danger", "risque"]),
|
|
107
|
+
callout("Check", "Validé", "Une étape réussie, un résultat attendu.", ["validé", "succès", "ok"]),
|
|
108
|
+
columns(2),
|
|
109
|
+
columns(3),
|
|
110
|
+
columns(4),
|
|
111
|
+
{
|
|
112
|
+
id: "steps",
|
|
113
|
+
label: "Étapes",
|
|
114
|
+
description: "Une marche à suivre numérotée.",
|
|
115
|
+
category: "Mise en page",
|
|
116
|
+
keywords: ["étapes", "steps", "procédure"],
|
|
117
|
+
snippet:
|
|
118
|
+
'<Steps>\n <Step title="Première étape">\n $|\n </Step>\n <Step title="Deuxième étape">\n </Step>\n</Steps>',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "tabs",
|
|
122
|
+
label: "Onglets",
|
|
123
|
+
description: "Plusieurs variantes d'un même contenu.",
|
|
124
|
+
category: "Mise en page",
|
|
125
|
+
keywords: ["onglets", "tabs"],
|
|
126
|
+
snippet: '<Tabs>\n <Tab title="Premier">\n $|\n </Tab>\n <Tab title="Second">\n </Tab>\n</Tabs>',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "accordion",
|
|
130
|
+
label: "Accordéon",
|
|
131
|
+
description: "Un détail replié, ouvert au clic.",
|
|
132
|
+
category: "Mise en page",
|
|
133
|
+
keywords: ["accordéon", "accordion", "replié", "faq"],
|
|
134
|
+
snippet: '<AccordionGroup>\n <Accordion title="Question">\n $|\n </Accordion>\n</AccordionGroup>',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "card",
|
|
138
|
+
label: "Carte",
|
|
139
|
+
description: "Un lien mis en avant, avec un titre.",
|
|
140
|
+
category: "Contenu",
|
|
141
|
+
keywords: ["carte", "card", "lien"],
|
|
142
|
+
snippet: '<Card title="Titre" href="/">\n $|\n</Card>',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "code-group",
|
|
146
|
+
label: "Groupe de code",
|
|
147
|
+
description: "Le même exemple en plusieurs langages.",
|
|
148
|
+
category: "Contenu",
|
|
149
|
+
keywords: ["code", "codegroup", "exemple"],
|
|
150
|
+
snippet: "<CodeGroup>\n\n```bash npm\n$|\n```\n\n```bash bun\n\n```\n\n</CodeGroup>",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: "frame",
|
|
154
|
+
label: "Cadre",
|
|
155
|
+
description: "Une image ou une capture, avec sa légende.",
|
|
156
|
+
category: "Contenu",
|
|
157
|
+
keywords: ["cadre", "frame", "image", "capture"],
|
|
158
|
+
snippet: '<Frame caption="Légende">\n \n</Frame>',
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
/** Texte à insérer et position du curseur dans ce texte. */
|
|
163
|
+
export function expandSnippet(snippet: string): { text: string; cursor: number } {
|
|
164
|
+
const cursor = snippet.indexOf("$|")
|
|
165
|
+
return cursor === -1 ? { text: snippet, cursor: snippet.length } : { text: snippet.replace("$|", ""), cursor }
|
|
166
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -56,6 +56,47 @@ const logo = z.union([
|
|
|
56
56
|
z.object({ light: z.string(), dark: z.string().optional(), href: z.string().optional() }),
|
|
57
57
|
])
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Réseaux du pied de page : clé de `footer.socials` (celles de Mintlify), libellé, icône de
|
|
61
|
+
* Starlight. Lu par le build et par le formulaire des réglages de la console.
|
|
62
|
+
*/
|
|
63
|
+
export const SOCIAL_NETWORKS = {
|
|
64
|
+
linkedin: { label: "LinkedIn", icon: "linkedin" },
|
|
65
|
+
github: { label: "GitHub", icon: "github" },
|
|
66
|
+
gitlab: { label: "GitLab", icon: "gitlab" },
|
|
67
|
+
x: { label: "X", icon: "x.com" },
|
|
68
|
+
youtube: { label: "YouTube", icon: "youtube" },
|
|
69
|
+
instagram: { label: "Instagram", icon: "instagram" },
|
|
70
|
+
facebook: { label: "Facebook", icon: "facebook" },
|
|
71
|
+
discord: { label: "Discord", icon: "discord" },
|
|
72
|
+
mastodon: { label: "Mastodon", icon: "mastodon" },
|
|
73
|
+
bluesky: { label: "Bluesky", icon: "blueSky" },
|
|
74
|
+
website: { label: "Site web", icon: "external" },
|
|
75
|
+
} as const
|
|
76
|
+
|
|
77
|
+
/** Ancienne écriture acceptée : `x.com` (Starlight) et `twitter` désignent X. */
|
|
78
|
+
const SOCIAL_ALIASES: Record<string, keyof typeof SOCIAL_NETWORKS> = { "x.com": "x", twitter: "x" }
|
|
79
|
+
|
|
80
|
+
export function socialNetwork(key: string): (typeof SOCIAL_NETWORKS)[keyof typeof SOCIAL_NETWORKS] | null {
|
|
81
|
+
const name = (SOCIAL_ALIASES[key] ?? key) as keyof typeof SOCIAL_NETWORKS
|
|
82
|
+
return SOCIAL_NETWORKS[name] ?? null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const REDIRECT_SOURCE = /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/
|
|
86
|
+
|
|
87
|
+
/** Ancienne adresse vers la nouvelle (champ `redirects` de Mintlify), sans motif. */
|
|
88
|
+
const redirect = z.object({
|
|
89
|
+
source: z.string().regex(REDIRECT_SOURCE, "adresse source attendue, commençant par « / » (ex. /guides/ancien)"),
|
|
90
|
+
destination: z
|
|
91
|
+
.string()
|
|
92
|
+
.min(1)
|
|
93
|
+
.refine(
|
|
94
|
+
(value) => /^\/(?!\/)/.test(value) || value.startsWith("https://"),
|
|
95
|
+
"destination attendue : un chemin du site (« /… ») ou une adresse https://",
|
|
96
|
+
),
|
|
97
|
+
permanent: z.boolean().optional(),
|
|
98
|
+
})
|
|
99
|
+
|
|
59
100
|
export const docsConfigSchema = z.object({
|
|
60
101
|
$schema: z.string().optional(),
|
|
61
102
|
name: z.string().min(1).max(80),
|
|
@@ -86,6 +127,7 @@ export const docsConfigSchema = z.object({
|
|
|
86
127
|
})
|
|
87
128
|
.optional(),
|
|
88
129
|
footer: z.object({ socials: z.record(z.string(), z.url()).optional() }).optional(),
|
|
130
|
+
redirects: z.array(redirect).max(500, "500 redirections au plus").optional(),
|
|
89
131
|
assistant: z
|
|
90
132
|
.object({
|
|
91
133
|
enabled: z.boolean().default(true),
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contrat de `POST /api/v1/feedback` entre le bas de chaque page (« Cette page t'a aidé ? »)
|
|
5
|
+
* et la console. Aucune donnée personnelle : la page, la réponse, un commentaire facultatif.
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_FEEDBACK_COMMENT = 1000
|
|
8
|
+
|
|
9
|
+
export const feedbackRequestSchema = z.object({
|
|
10
|
+
project: z.string().regex(/^[a-z0-9]{8,64}$/),
|
|
11
|
+
/** Chemin de la page, `base` compris (`/docs/guides/publier/`). */
|
|
12
|
+
page: z.string().startsWith("/").max(512),
|
|
13
|
+
helpful: z.boolean(),
|
|
14
|
+
comment: z.string().trim().max(MAX_FEEDBACK_COMMENT).optional(),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export type FeedbackRequest = z.infer<typeof feedbackRequestSchema>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { compile } from "@mdx-js/mdx"
|
|
2
|
+
import type { Root } from "mdast"
|
|
3
|
+
import remarkGfm from "remark-gfm"
|
|
4
|
+
import { parse as parseYaml } from "yaml"
|
|
5
|
+
|
|
6
|
+
import { isMdxComponent } from "./components.ts"
|
|
7
|
+
import { parseDocsConfig } from "./config.ts"
|
|
8
|
+
import { missingPages, normalizeNavigation, pageToSlug } from "./navigation.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ce que le build refuserait, vérifié avant de publier : l'éditeur de la console bloque
|
|
12
|
+
* la publication sur la moindre erreur, avec la ligne à corriger. Même compilateur MDX
|
|
13
|
+
* que le build (`@mdx-js/mdx`, GFM), même schéma de `docs.json`, même règle de pages.
|
|
14
|
+
*/
|
|
15
|
+
export type Problem = { file: string; line: number | null; column: number | null; message: string }
|
|
16
|
+
|
|
17
|
+
/** Une page de la doc : `.mdx` ou `.md` à l'intérieur du dossier. */
|
|
18
|
+
export const PAGE_FILE = /\.(mdx|md)$/i
|
|
19
|
+
|
|
20
|
+
const IMAGE_FILE = /\.(png|jpe?g|gif|svg|webp|avif|ico)$/i
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fichier que l'éditeur peut écrire, relatif au dossier de la doc : `docs.json`, pages et
|
|
24
|
+
* images. Jamais un dossier caché ni la configuration du build (même règle que Host,
|
|
25
|
+
* `isEditablePath` d'agenthost, qui revérifie).
|
|
26
|
+
*/
|
|
27
|
+
export function isDocFile(path: string): boolean {
|
|
28
|
+
if (path.length === 0 || path.length > 300) return false
|
|
29
|
+
if (path.startsWith("/") || path.endsWith("/") || path.includes("\\")) return false
|
|
30
|
+
const segments = path.split("/")
|
|
31
|
+
if (segments.some((s) => s === "" || s === "." || s === ".." || s.startsWith("."))) return false
|
|
32
|
+
if (segments.includes("node_modules") || segments[0] === "dist") return false
|
|
33
|
+
if (!/^[\p{L}\p{N} ._/()+@-]+$/u.test(path)) return false
|
|
34
|
+
return path === "docs.json" || PAGE_FILE.test(path) || IMAGE_FILE.test(path)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isImageFile(path: string): boolean {
|
|
38
|
+
return IMAGE_FILE.test(path)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fichiers Markdown du dossier qui ne sont pas des pages (`template/src/content.config.ts`). */
|
|
42
|
+
const NOT_PAGES = new Set(["README.md", "CHANGELOG.md"])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Une page du site : `.mdx` ou `.md`, sauf un extrait (`_nom.mdx`, inclus par d'autres pages)
|
|
46
|
+
* et le README ou le CHANGELOG à la racine, que le build ignore.
|
|
47
|
+
*/
|
|
48
|
+
export function isPageFile(file: string): boolean {
|
|
49
|
+
return PAGE_FILE.test(file) && !NOT_PAGES.has(file) && !(file.split("/").at(-1) ?? "").startsWith("_")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `guides/publier.mdx` → `guides/publier` : le chemin de page de `docs.json` ; `null` pour un autre fichier. */
|
|
53
|
+
export function pageOfFile(file: string): string | null {
|
|
54
|
+
return isPageFile(file) ? file.replace(PAGE_FILE, "") : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n?---[ \t]*(?:\r?\n|$)/
|
|
58
|
+
|
|
59
|
+
export type Frontmatter = { raw: string | null; body: string; lines: number }
|
|
60
|
+
|
|
61
|
+
/** En-tête YAML d'une page et corps, avec le nombre de lignes de l'en-tête (pour garder les numéros de ligne). */
|
|
62
|
+
export function splitFrontmatter(source: string): Frontmatter {
|
|
63
|
+
const match = FRONTMATTER.exec(source)
|
|
64
|
+
if (!match) return { raw: null, body: source, lines: 0 }
|
|
65
|
+
const header = match[0]
|
|
66
|
+
const lines = header.split("\n").length - (header.endsWith("\n") ? 1 : 0)
|
|
67
|
+
return { raw: match[1] ?? "", body: source.slice(header.length), lines }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Lit l'en-tête d'une page : objet YAML, ou `null` s'il manque ou ne se lit pas. */
|
|
71
|
+
export function readFrontmatter(source: string): Record<string, unknown> | null {
|
|
72
|
+
const { raw } = splitFrontmatter(source)
|
|
73
|
+
if (raw === null) return null
|
|
74
|
+
try {
|
|
75
|
+
const data: unknown = parseYaml(raw)
|
|
76
|
+
return data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : null
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Position = { start?: { line?: number; column?: number } }
|
|
83
|
+
type MdxNode = { type: string; name?: string | null; position?: Position; children?: Array<MdxNode> }
|
|
84
|
+
|
|
85
|
+
/** Composants appelés par la page sans exister : le build échouerait au rendu. */
|
|
86
|
+
function unknownComponents(problems: Array<Problem>, file: string) {
|
|
87
|
+
return () => (tree: Root) => {
|
|
88
|
+
const declared = new Set<string>()
|
|
89
|
+
const walk = (node: MdxNode) => {
|
|
90
|
+
if (node.type === "mdxjsEsm") {
|
|
91
|
+
const value = (node as unknown as { value: string }).value
|
|
92
|
+
for (const match of value.matchAll(/import\s+(?:\{([^}]*)\}|([A-Za-z_$][\w$]*))/g)) {
|
|
93
|
+
for (const name of (match[1] ?? match[2] ?? "").split(","))
|
|
94
|
+
declared.add(
|
|
95
|
+
name
|
|
96
|
+
.split(/\s+as\s+/)
|
|
97
|
+
.pop()!
|
|
98
|
+
.trim(),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
for (const match of value.matchAll(/export\s+(?:const|function)\s+([A-Za-z_$][\w$]*)/g)) declared.add(match[1]!)
|
|
102
|
+
}
|
|
103
|
+
for (const child of node.children ?? []) walk(child)
|
|
104
|
+
}
|
|
105
|
+
walk(tree as unknown as MdxNode)
|
|
106
|
+
const check = (node: MdxNode) => {
|
|
107
|
+
if ((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && node.name) {
|
|
108
|
+
const root = node.name.split(".")[0]!
|
|
109
|
+
if (/^[A-Z]/.test(root) && !isMdxComponent(root) && !declared.has(root))
|
|
110
|
+
problems.push({
|
|
111
|
+
file,
|
|
112
|
+
line: node.position?.start?.line ?? null,
|
|
113
|
+
column: node.position?.start?.column ?? null,
|
|
114
|
+
message: `Le composant <${node.name}> n'existe pas. Composants disponibles : <Note>, <Tip>, <Card>, <Steps>…`,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
for (const child of node.children ?? []) check(child)
|
|
118
|
+
}
|
|
119
|
+
check(tree as unknown as MdxNode)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const TRANSLATIONS: Array<[RegExp, (m: RegExpMatchArray) => string]> = [
|
|
124
|
+
[/Expected a closing tag for `<([^>]+)>`/, (m) => `La balise <${m[1]}> n'est jamais fermée.`],
|
|
125
|
+
[
|
|
126
|
+
/Unexpected closing slash `\/` in tag, expected an open tag first/,
|
|
127
|
+
() => "Balise fermante sans balise ouvrante correspondante.",
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
/Unexpected closing tag `<\/([^>]+)>`, expected corresponding closing tag for `<([^>]+)>`/,
|
|
131
|
+
(m) => `</${m[1]}> ferme la mauvaise balise : <${m[2]}> est encore ouverte.`,
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
/Expected the closing tag `<\/([^>]+)>` either after the end of/,
|
|
135
|
+
(m) =>
|
|
136
|
+
`</${m[1]}> est mal placée : ouvre et ferme <${m[1]}> sur leurs propres lignes, ou sur la même ligne que le texte.`,
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
/Could not parse expression with acorn/,
|
|
140
|
+
() => "Expression entre accolades illisible : échappe « { » par « \\{ » si c'est du texte.",
|
|
141
|
+
],
|
|
142
|
+
[/Could not parse import\/exports with acorn/, () => "Ligne import ou export illisible."],
|
|
143
|
+
[
|
|
144
|
+
/Unexpected end of file in expression, expected a corresponding closing brace for `\{`/,
|
|
145
|
+
() => "Accolade « { » jamais fermée : échappe-la par « \\{ » si c'est du texte.",
|
|
146
|
+
],
|
|
147
|
+
[
|
|
148
|
+
/Unexpected character `(.)` \(U\+[0-9A-F]+\) before name/,
|
|
149
|
+
(m) => `Caractère « ${m[1]} » inattendu après « < » : écris « < » pour un chevron dans le texte.`,
|
|
150
|
+
],
|
|
151
|
+
[/Unexpected lazy line in/, () => "Ligne mal indentée à l'intérieur d'un bloc (citation, liste ou composant)."],
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
function translate(reason: string): string {
|
|
155
|
+
for (const [pattern, render] of TRANSLATIONS) {
|
|
156
|
+
const match = reason.match(pattern)
|
|
157
|
+
if (match) return render(match)
|
|
158
|
+
}
|
|
159
|
+
return `Syntaxe MDX invalide : ${reason}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type Point = { line?: number; column?: number }
|
|
163
|
+
type VFileMessageLike = {
|
|
164
|
+
reason?: string
|
|
165
|
+
message?: string
|
|
166
|
+
line?: number | null
|
|
167
|
+
column?: number | null
|
|
168
|
+
place?: (Point & { start?: Point }) | null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Position d'une erreur de MDX : son point, sinon celui cité dans le message (`(7:1-7:7)`). */
|
|
172
|
+
function positionOf(failure: VFileMessageLike, reason: string): { line: number | null; column: number | null } {
|
|
173
|
+
const point = failure.place?.start ?? failure.place
|
|
174
|
+
if (failure.line) return { line: failure.line, column: failure.column ?? null }
|
|
175
|
+
if (point?.line) return { line: point.line, column: point.column ?? null }
|
|
176
|
+
const cited = reason.match(/\((\d+):(\d+)/)
|
|
177
|
+
return cited ? { line: Number(cited[1]), column: Number(cited[2]) } : { line: null, column: null }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** En-tête lu ; `undefined` (et une erreur notée) s'il ne se lit pas. */
|
|
181
|
+
function checkYaml(problems: Array<Problem>, file: string, raw: string): unknown {
|
|
182
|
+
try {
|
|
183
|
+
return parseYaml(raw)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const position = (error as { linePos?: Array<{ line: number; col: number }> }).linePos?.[0]
|
|
186
|
+
problems.push({
|
|
187
|
+
file,
|
|
188
|
+
line: position ? position.line + 1 : 1,
|
|
189
|
+
column: position?.col ?? null,
|
|
190
|
+
message: "En-tête illisible : vérifie les deux-points et les guillemets (YAML).",
|
|
191
|
+
})
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Vérifie une page : en-tête YAML avec un `title` (Starlight l'exige), compilation MDX
|
|
198
|
+
* comme au build, composants connus. Les numéros de ligne sont ceux du fichier entier.
|
|
199
|
+
*/
|
|
200
|
+
export async function checkPage(file: string, source: string): Promise<Array<Problem>> {
|
|
201
|
+
const problems: Array<Problem> = []
|
|
202
|
+
const front = splitFrontmatter(source)
|
|
203
|
+
// Un extrait ou le README n'est pas une page : seule la syntaxe compte, pas l'en-tête.
|
|
204
|
+
if (!isPageFile(file)) {
|
|
205
|
+
if (front.raw !== null) checkYaml(problems, file, front.raw)
|
|
206
|
+
} else if (front.raw === null) {
|
|
207
|
+
problems.push({
|
|
208
|
+
file,
|
|
209
|
+
line: 1,
|
|
210
|
+
column: 1,
|
|
211
|
+
message: "Il manque l'en-tête de la page : commence par ---, title: Le titre, puis ---.",
|
|
212
|
+
})
|
|
213
|
+
} else {
|
|
214
|
+
const data = checkYaml(problems, file, front.raw)
|
|
215
|
+
const title = data && typeof data === "object" ? (data as Record<string, unknown>).title : undefined
|
|
216
|
+
if (data !== undefined && (typeof title !== "string" || !title.trim()))
|
|
217
|
+
problems.push({
|
|
218
|
+
file,
|
|
219
|
+
line: 2,
|
|
220
|
+
column: 1,
|
|
221
|
+
message: "L'en-tête doit donner un titre : title: Le titre de la page.",
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Les lignes de l'en-tête restent, vides : les positions de MDX tombent sur la bonne ligne.
|
|
226
|
+
const body = "\n".repeat(front.lines) + front.body
|
|
227
|
+
try {
|
|
228
|
+
await compile(body, {
|
|
229
|
+
format: file.toLowerCase().endsWith(".md") ? "md" : "mdx",
|
|
230
|
+
remarkPlugins: [remarkGfm, unknownComponents(problems, file)],
|
|
231
|
+
})
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const failure = error as VFileMessageLike
|
|
234
|
+
const reason = String(failure.reason ?? failure.message ?? error)
|
|
235
|
+
problems.push({ file, ...positionOf(failure, reason), message: translate(reason) })
|
|
236
|
+
}
|
|
237
|
+
return problems.sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Vérifie `docs.json` et sa navigation contre les fichiers de la doc : schéma, une seule
|
|
242
|
+
* forme de navigation, et chaque page citée présente dans le dossier.
|
|
243
|
+
*/
|
|
244
|
+
export function checkSite(docsJson: string, files: Iterable<string>): Array<Problem> {
|
|
245
|
+
const parsed = parseDocsConfig(docsJson)
|
|
246
|
+
if (!parsed.ok) return parsed.errors.map((message) => ({ file: "docs.json", line: null, column: null, message }))
|
|
247
|
+
const existing = new Set<string>()
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
const page = pageOfFile(file)
|
|
250
|
+
if (page) existing.add(pageToSlug(page))
|
|
251
|
+
}
|
|
252
|
+
const problems: Array<Problem> = missingPages(normalizeNavigation(parsed.config), existing).map((page) => ({
|
|
253
|
+
file: "docs.json",
|
|
254
|
+
line: null,
|
|
255
|
+
column: null,
|
|
256
|
+
message: `La navigation cite la page ${page}, mais ${page}.mdx n'existe pas.`,
|
|
257
|
+
}))
|
|
258
|
+
const slug = (path: string) =>
|
|
259
|
+
path
|
|
260
|
+
.split(/[?#]/)[0]!
|
|
261
|
+
.replace(/^\/+|\/+$/g, "")
|
|
262
|
+
.toLowerCase()
|
|
263
|
+
const sources = new Set<string>()
|
|
264
|
+
for (const redirect of parsed.config.redirects ?? []) {
|
|
265
|
+
const source = slug(redirect.source)
|
|
266
|
+
const say = (message: string) => problems.push({ file: "docs.json", line: null, column: null, message })
|
|
267
|
+
if (sources.has(source)) say(`Deux redirections partent de ${redirect.source}.`)
|
|
268
|
+
sources.add(source)
|
|
269
|
+
// Une redirection depuis une page qui existe la cacherait : le build écrirait les deux au même endroit.
|
|
270
|
+
if (existing.has(source))
|
|
271
|
+
say(`La redirection ${redirect.source} masque une page qui existe : retire-la ou supprime la page.`)
|
|
272
|
+
if (redirect.destination.startsWith("/") && !existing.has(slug(redirect.destination)))
|
|
273
|
+
say(`La redirection ${redirect.source} mène à ${redirect.destination}, une page qui n'existe pas.`)
|
|
274
|
+
}
|
|
275
|
+
return problems
|
|
276
|
+
}
|