@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.
Files changed (62) hide show
  1. package/docs.schema.json +21 -0
  2. package/package.json +3 -1
  3. package/src/build/auto-import.ts +5 -24
  4. package/src/core/components.ts +166 -0
  5. package/src/core/config.ts +42 -0
  6. package/src/core/feedback-api.ts +17 -0
  7. package/src/core/validate.ts +276 -0
  8. package/src/integration.ts +29 -15
  9. package/src/overrides/Footer.astro +154 -0
  10. package/src/overrides/Head.astro +14 -14
  11. package/src/overrides/PageTitle.astro +95 -95
  12. package/src/overrides/Sidebar.astro +60 -60
  13. package/src/overrides/SocialIcons.astro +59 -0
  14. package/template/.astro/content-assets.mjs +4 -1
  15. package/template/.astro/content-modules.mjs +15 -15
  16. package/template/.build/.well-known/llms.txt +15 -15
  17. package/template/.build/404.html +5 -5
  18. package/template/.build/_astro/Search.astro_astro_type_script_index_0_lang.g9hoxVJg.js +1 -0
  19. package/template/.build/_astro/common.CQYUsl_5.css +3 -0
  20. package/template/.build/_astro/{ui-core.CrG20ya_.js → ui-core.Clh5jUUO.js} +1 -1
  21. package/template/.build/_hgdc/corpus.json +1 -1
  22. package/template/.build/demarrer/premiere-doc/index.html +15 -15
  23. package/template/.build/demarrer/premiere-doc.md +3 -3
  24. package/template/.build/explications/donnees/index.html +10 -10
  25. package/template/.build/explications/fonctionnement/index.html +10 -11
  26. package/template/.build/guides/agents/index.html +11 -11
  27. package/template/.build/guides/agents.md +1 -1
  28. package/template/.build/guides/assistant/index.html +11 -11
  29. package/template/.build/guides/composants/index.html +19 -19
  30. package/template/.build/guides/composants.md +10 -8
  31. package/template/.build/guides/ecrire/index.html +12 -13
  32. package/template/.build/guides/elements/index.html +1 -0
  33. package/template/.build/guides/navigation/index.html +12 -13
  34. package/template/.build/guides/publier/index.html +14 -23
  35. package/template/.build/guides/publier.md +1 -1
  36. package/template/.build/guides/theme/index.html +12 -13
  37. package/template/.build/index.html +11 -12
  38. package/template/.build/index.md +9 -9
  39. package/template/.build/llms-full.txt +59 -63
  40. package/template/.build/llms.txt +15 -15
  41. package/template/.build/pagefind/fragment/fr-ch_148313b.pf_fragment +0 -0
  42. package/template/.build/pagefind/fragment/fr-ch_35f556a.pf_fragment +0 -0
  43. package/template/.build/pagefind/fragment/fr-ch_48ca71e.pf_fragment +0 -0
  44. package/template/.build/pagefind/fragment/fr-ch_c589c2a.pf_fragment +0 -0
  45. package/template/.build/pagefind/index/fr-ch_c5ced52.pf_index +0 -0
  46. package/template/.build/pagefind/pagefind-entry.json +1 -1
  47. package/template/.build/pagefind/pagefind.fr-ch_8a246aa922758.pf_meta +0 -0
  48. package/template/.build/reference/api-assistant/index.html +12 -12
  49. package/template/.build/reference/api-assistant.md +2 -12
  50. package/template/.build/reference/cli/index.html +10 -10
  51. package/template/.build/reference/docs-json/index.html +35 -11
  52. package/template/.build/reference/docs-json.md +18 -14
  53. package/template/.build/reference/mcp/index.html +11 -11
  54. package/template/node_modules/.vite/deps/_metadata.json +8 -8
  55. package/template/.build/_astro/Search.astro_astro_type_script_index_0_lang.Cn3-p701.js +0 -1
  56. package/template/.build/_astro/common.C-MpX45O.css +0 -3
  57. package/template/.build/pagefind/fragment/fr-ch_68e734b.pf_fragment +0 -0
  58. package/template/.build/pagefind/fragment/fr-ch_cfa4336.pf_fragment +0 -0
  59. package/template/.build/pagefind/fragment/fr-ch_e02aa49.pf_fragment +0 -0
  60. package/template/.build/pagefind/fragment/fr-ch_e254a54.pf_fragment +0 -0
  61. package/template/.build/pagefind/index/fr-ch_a71411b.pf_index +0 -0
  62. 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.2.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": {
@@ -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 ![Description de l\'image]($|)\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
+ }
@@ -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 « &lt; » 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
+ }