@hgdc/docs 0.1.1 → 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 (82) hide show
  1. package/docs.schema.json +25 -0
  2. package/package.json +3 -1
  3. package/src/build/auto-import.ts +5 -24
  4. package/src/build/base-links.ts +35 -0
  5. package/src/build/outputs.ts +17 -4
  6. package/src/core/components.ts +166 -0
  7. package/src/core/config.ts +50 -0
  8. package/src/core/feedback-api.ts +17 -0
  9. package/src/core/llms.ts +8 -5
  10. package/src/core/navigation.ts +24 -0
  11. package/src/core/validate.ts +276 -0
  12. package/src/integration.ts +71 -21
  13. package/src/overrides/Footer.astro +154 -0
  14. package/src/overrides/Head.astro +4 -2
  15. package/src/overrides/PageTitle.astro +3 -2
  16. package/src/overrides/Sidebar.astro +2 -2
  17. package/src/overrides/SocialIcons.astro +59 -0
  18. package/template/.astro/content-assets.mjs +4 -1
  19. package/template/.build/.well-known/llms.txt +5 -5
  20. package/template/.build/404.html +5 -5
  21. package/template/.build/_astro/{common.8GHCkW7i.css → common.CQYUsl_5.css} +2 -2
  22. package/template/.build/_hgdc/corpus.json +1 -1
  23. package/template/.build/demarrer/premiere-doc/index.html +12 -12
  24. package/template/.build/demarrer/premiere-doc.md +1 -1
  25. package/template/.build/explications/donnees/index.html +11 -11
  26. package/template/.build/explications/donnees.md +1 -1
  27. package/template/.build/explications/fonctionnement/index.html +12 -12
  28. package/template/.build/explications/fonctionnement.md +3 -3
  29. package/template/.build/guides/agents/index.html +15 -10
  30. package/template/.build/guides/agents.md +15 -1
  31. package/template/.build/guides/assistant/index.html +14 -12
  32. package/template/.build/guides/assistant.md +5 -1
  33. package/template/.build/guides/composants/index.html +15 -15
  34. package/template/.build/guides/ecrire/index.html +12 -12
  35. package/template/.build/guides/ecrire.md +1 -1
  36. package/template/.build/guides/elements/index.html +1 -0
  37. package/template/.build/guides/navigation/index.html +11 -11
  38. package/template/.build/guides/publier/index.html +16 -16
  39. package/template/.build/guides/publier.md +8 -8
  40. package/template/.build/guides/theme/index.html +11 -11
  41. package/template/.build/guides/theme.md +1 -1
  42. package/template/.build/index.html +11 -11
  43. package/template/.build/index.md +5 -5
  44. package/template/.build/llms-full.txt +69 -47
  45. package/template/.build/llms.txt +5 -5
  46. package/template/.build/pagefind/fragment/fr-ch_2a431a9.pf_fragment +0 -0
  47. package/template/.build/pagefind/fragment/fr-ch_35f556a.pf_fragment +0 -0
  48. package/template/.build/pagefind/fragment/fr-ch_456586a.pf_fragment +0 -0
  49. package/template/.build/pagefind/fragment/fr-ch_48ca71e.pf_fragment +0 -0
  50. package/template/.build/pagefind/fragment/fr-ch_72b91f4.pf_fragment +0 -0
  51. package/template/.build/pagefind/fragment/fr-ch_8f256dd.pf_fragment +0 -0
  52. package/template/.build/pagefind/fragment/fr-ch_95a15cc.pf_fragment +0 -0
  53. package/template/.build/pagefind/fragment/fr-ch_aa80c55.pf_fragment +0 -0
  54. package/template/.build/pagefind/fragment/fr-ch_ab79738.pf_fragment +0 -0
  55. package/template/.build/pagefind/fragment/fr-ch_c589c2a.pf_fragment +0 -0
  56. package/template/.build/pagefind/fragment/fr-ch_dab8f5e.pf_fragment +0 -0
  57. package/template/.build/pagefind/fragment/fr-ch_de60b43.pf_fragment +0 -0
  58. package/template/.build/pagefind/index/fr-ch_c5ced52.pf_index +0 -0
  59. package/template/.build/pagefind/pagefind-entry.json +1 -1
  60. package/template/.build/pagefind/pagefind.fr-ch_8a246aa922758.pf_meta +0 -0
  61. package/template/.build/reference/api-assistant/index.html +11 -11
  62. package/template/.build/reference/api-assistant.md +3 -3
  63. package/template/.build/reference/cli/index.html +11 -11
  64. package/template/.build/reference/cli.md +5 -5
  65. package/template/.build/reference/docs-json/index.html +35 -11
  66. package/template/.build/reference/docs-json.md +19 -15
  67. package/template/.build/reference/mcp/index.html +10 -10
  68. package/template/node_modules/.vite/deps/_metadata.json +8 -8
  69. package/template/.build/pagefind/fragment/fr-ch_424fd16.pf_fragment +0 -0
  70. package/template/.build/pagefind/fragment/fr-ch_5a4d77d.pf_fragment +0 -0
  71. package/template/.build/pagefind/fragment/fr-ch_6c36edc.pf_fragment +0 -0
  72. package/template/.build/pagefind/fragment/fr-ch_73d7887.pf_fragment +0 -0
  73. package/template/.build/pagefind/fragment/fr-ch_8234ed3.pf_fragment +0 -0
  74. package/template/.build/pagefind/fragment/fr-ch_8f8a19e.pf_fragment +0 -0
  75. package/template/.build/pagefind/fragment/fr-ch_9f1b981.pf_fragment +0 -0
  76. package/template/.build/pagefind/fragment/fr-ch_a19039d.pf_fragment +0 -0
  77. package/template/.build/pagefind/fragment/fr-ch_d72cc1e.pf_fragment +0 -0
  78. package/template/.build/pagefind/fragment/fr-ch_d732215.pf_fragment +0 -0
  79. package/template/.build/pagefind/fragment/fr-ch_e78d58b.pf_fragment +0 -0
  80. package/template/.build/pagefind/fragment/fr-ch_e92f972.pf_fragment +0 -0
  81. package/template/.build/pagefind/index/fr-ch_f94c95d.pf_index +0 -0
  82. package/template/.build/pagefind/pagefind.fr-ch_fb78ab42961f4.pf_meta +0 -0
package/docs.schema.json CHANGED
@@ -23,6 +23,10 @@
23
23
  "type": "string",
24
24
  "enum": ["fr", "en", "de", "it"]
25
25
  },
26
+ "base": {
27
+ "type": "string",
28
+ "pattern": "^(?:\\/[a-z0-9][a-z0-9_-]*)+$"
29
+ },
26
30
  "colors": {
27
31
  "type": "object",
28
32
  "properties": {
@@ -244,6 +248,27 @@
244
248
  }
245
249
  }
246
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
+ },
247
272
  "assistant": {
248
273
  "type": "object",
249
274
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/docs",
3
- "version": "0.1.1",
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,35 @@
1
+ import type { Root } from "mdast"
2
+
3
+ import { withBase } from "../core/navigation.ts"
4
+
5
+ type Node = { type: string; url?: unknown; attributes?: unknown; children?: Array<Node> }
6
+ type Attribute = { type: string; name?: string; value?: unknown }
7
+
8
+ /** Attributs JSX qui portent une adresse : `<Card href>`, `<img src>`, `<a href>`, `<video poster>`. */
9
+ const URL_ATTRIBUTES = new Set(["href", "src", "poster"])
10
+
11
+ /**
12
+ * Site servi sous un sous-chemin (`base: "/docs"`) : les liens et médias écrits depuis la
13
+ * racine du site (`/guides/publier/`, `/images/x.png`) reçoivent le sous-chemin, dans le
14
+ * Markdown (`[…](/…)`, `![…](/…)`, définitions) comme dans les composants MDX. Le rédacteur
15
+ * écrit ses liens comme pour un site à la racine ; la doc reste déplaçable.
16
+ */
17
+ export function remarkBaseLinks(options: { base: string }) {
18
+ const { base } = options
19
+ return (tree: Root) => {
20
+ if (!base) return
21
+ const walk = (node: Node) => {
22
+ if (typeof node.url === "string") node.url = withBase(base, node.url)
23
+ // Les directives (`:::note{…}`) portent un objet d'attributs : seuls ceux de JSX nous intéressent.
24
+ for (const attribute of Array.isArray(node.attributes) ? (node.attributes as Array<Attribute>) : [])
25
+ if (
26
+ attribute.type === "mdxJsxAttribute" &&
27
+ URL_ATTRIBUTES.has(attribute.name ?? "") &&
28
+ typeof attribute.value === "string"
29
+ )
30
+ attribute.value = withBase(base, attribute.value)
31
+ for (const child of node.children ?? []) walk(child)
32
+ }
33
+ walk(tree as unknown as Node)
34
+ }
35
+ }
@@ -10,10 +10,18 @@ import { unified } from "unified"
10
10
  import type { DocsConfig } from "../core/config.ts"
11
11
  import { CORPUS_FORMAT, CORPUS_PATH, MAX_PAGE_CHARS, type Corpus } from "../core/corpus.ts"
12
12
  import { llmsFullTxt, llmsTxt, markdownPath, type LlmsPage } from "../core/llms.ts"
13
- import { allPages, locatePage, pageToPath, type NavTree } from "../core/navigation.ts"
13
+ import { allPages, locatePage, pageToPath, withBase, type NavTree } from "../core/navigation.ts"
14
14
  import { htmlToMarkdown } from "./markdown.ts"
15
15
 
16
- type Input = { outDir: string; pathnames: Array<string>; config: DocsConfig; tree: NavTree; site: string | null }
16
+ type Input = {
17
+ outDir: string
18
+ pathnames: Array<string>
19
+ config: DocsConfig
20
+ tree: NavTree
21
+ site: string | null
22
+ /** Sous-chemin du site (`/docs`) : porté par les chemins publiés, pas par les fichiers écrits ici. */
23
+ base?: string
24
+ }
17
25
 
18
26
  /** `guides/publier/` (pathname d'Astro) → `/guides/publier/`. */
19
27
  const normalize = (pathname: string) => `/${pathname}`.replace(/\/+/g, "/").replace(/([^/])$/, "$1/")
@@ -24,6 +32,7 @@ const normalize = (pathname: string) => `/${pathname}`.replace(/\/+/g, "/").repl
24
32
  * navigation y entrent : une page hors navigation n'est pas annoncée aux agents.
25
33
  */
26
34
  export async function writeMachineReadable(input: Input): Promise<{ pages: number }> {
35
+ const base = input.base ?? ""
27
36
  const byPath = new Map(allPages(input.tree).map((page) => [pageToPath(page), page]))
28
37
  const pages = new Map<string, LlmsPage>()
29
38
  const corpusPages: Corpus["pages"] = []
@@ -45,7 +54,7 @@ export async function writeMachineReadable(input: Input): Promise<{ pages: numbe
45
54
  pages.set(page, entry)
46
55
  await write(input.outDir, markdownPath(path), `# ${title}\n\n${markdown}\n`)
47
56
  corpusPages.push({
48
- path,
57
+ path: withBase(base, path),
49
58
  title,
50
59
  description: entry.description,
51
60
  version: locatePage(input.tree, page)?.version.id ?? null,
@@ -57,6 +66,7 @@ export async function writeMachineReadable(input: Input): Promise<{ pages: numbe
57
66
  name: input.config.name,
58
67
  description: input.config.description ?? null,
59
68
  site: input.site,
69
+ base,
60
70
  tree: input.tree,
61
71
  pages,
62
72
  }
@@ -75,7 +85,7 @@ export async function writeMachineReadable(input: Input): Promise<{ pages: numbe
75
85
  builtAt: new Date().toISOString(),
76
86
  theme: {
77
87
  primary: input.config.colors?.primary ?? null,
78
- logo: typeof logo === "string" ? logo : (logo?.light ?? null),
88
+ logo: withLogoBase(base, typeof logo === "string" ? logo : (logo?.light ?? null)),
79
89
  },
80
90
  versions: input.tree.versions.map(({ id, label, isDefault }) => ({ id, label, isDefault })),
81
91
  pages: corpusPages,
@@ -84,6 +94,9 @@ export async function writeMachineReadable(input: Input): Promise<{ pages: numbe
84
94
  return { pages: pages.size }
85
95
  }
86
96
 
97
+ const withLogoBase = (base: string, logo: string | null) =>
98
+ logo ? withBase(base, logo.startsWith("/") || logo.includes("://") ? logo : `/${logo}`) : null
99
+
87
100
  async function write(outDir: string, path: string, content: string): Promise<void> {
88
101
  const target = join(outDir, path)
89
102
  await mkdir(dirname(target), { recursive: true })
@@ -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
+ }
@@ -16,6 +16,8 @@ const PAGE = /^(?!\/)(?!.*\.\.)[a-z0-9][a-z0-9/_-]*$/i
16
16
  const PROJECT_ID_PATTERN = /^[a-z0-9]{8,64}$/
17
17
 
18
18
  const hex = z.string().regex(HEX, "couleur attendue au format #rrggbb")
19
+ /** Sous-chemin du site (`/docs`) : un ou plusieurs segments, sans « / » final. */
20
+ const BASE = /^(?:\/[a-z0-9][a-z0-9_-]*)+$/i
19
21
 
20
22
  export type NavPageRef = string
21
23
  export type NavGroupInput = { group: string; pages: Array<NavPageRef | NavGroupInput>; expanded?: boolean }
@@ -54,6 +56,47 @@ const logo = z.union([
54
56
  z.object({ light: z.string(), dark: z.string().optional(), href: z.string().optional() }),
55
57
  ])
56
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
+
57
100
  export const docsConfigSchema = z.object({
58
101
  $schema: z.string().optional(),
59
102
  name: z.string().min(1).max(80),
@@ -64,6 +107,12 @@ export const docsConfigSchema = z.object({
64
107
  .regex(PROJECT_ID_PATTERN, "identifiant de projet attendu, copié depuis la console docs.hgdc.ch")
65
108
  .optional(),
66
109
  language: z.enum(["fr", "en", "de", "it"]).default("fr"),
110
+ /**
111
+ * Sous-chemin où la doc est servie, quand un produit la publie derrière un proxy
112
+ * (`https://app.exemple.ch/docs`). Liens, médias, recherche, `llms.txt` et pages `.md`
113
+ * le portent ; le site est écrit dans `dist/<base>/`.
114
+ */
115
+ base: z.string().regex(BASE, "sous-chemin attendu, sans « / » final (ex. /docs)").optional(),
67
116
  colors: z.object({ primary: hex, light: hex.optional(), dark: hex.optional() }).optional(),
68
117
  logo: logo.optional(),
69
118
  favicon: z.string().optional(),
@@ -78,6 +127,7 @@ export const docsConfigSchema = z.object({
78
127
  })
79
128
  .optional(),
80
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(),
81
131
  assistant: z
82
132
  .object({
83
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>
package/src/core/llms.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { NavItem, NavTree } from "./navigation.ts"
2
- import { pagesOf as pagesIn, pageToPath } from "./navigation.ts"
2
+ import { pagesOf as pagesIn, pageToPath, withBase } from "./navigation.ts"
3
3
 
4
4
  /**
5
5
  * `llms.txt` et `llms-full.txt` au format de llmstxt.org : un H1, un résumé en citation,
@@ -12,6 +12,8 @@ export type LlmsInput = {
12
12
  name: string
13
13
  description: string | null
14
14
  site: string | null
15
+ /** Sous-chemin du site (`/docs`), `""` ou absent à la racine. */
16
+ base?: string
15
17
  tree: NavTree
16
18
  pages: ReadonlyMap<string, LlmsPage>
17
19
  }
@@ -22,14 +24,15 @@ export function markdownPath(path: string): string {
22
24
  return trimmed ? `${trimmed}.md` : "/index.md"
23
25
  }
24
26
 
25
- function url(site: string | null, path: string): string {
26
- return site ? new URL(path, site).toString() : path
27
+ function url(input: Pick<LlmsInput, "site" | "base">, path: string): string {
28
+ const full = withBase(input.base ?? "", path)
29
+ return input.site ? new URL(full, input.site).toString() : full
27
30
  }
28
31
 
29
32
  function link(input: LlmsInput, page: string): string | null {
30
33
  const data = input.pages.get(page)
31
34
  if (!data) return null
32
- const target = url(input.site, markdownPath(pageToPath(page)))
35
+ const target = url(input, markdownPath(pageToPath(page)))
33
36
  return `- [${data.title}](${target})${data.description ? `: ${data.description}` : ""}`
34
37
  }
35
38
 
@@ -89,7 +92,7 @@ export function llmsFullTxt(input: LlmsInput, order: Array<string>): string {
89
92
  for (const page of order) {
90
93
  const data = input.pages.get(page)
91
94
  if (!data) continue
92
- parts.push(`\n---\n\nSource : ${url(input.site, pageToPath(page))}\n\n# ${data.title}\n\n${data.markdown.trim()}`)
95
+ parts.push(`\n---\n\nSource : ${url(input, pageToPath(page))}\n\n# ${data.title}\n\n${data.markdown.trim()}`)
93
96
  }
94
97
  return `${parts.join("\n").trim()}\n`
95
98
  }
@@ -82,6 +82,30 @@ export function pageToPath(page: string): string {
82
82
  return slug ? `/${slug}/` : "/"
83
83
  }
84
84
 
85
+ /** Sous-chemin du site tel que docs.json le déclare, ou `""` pour un site à la racine. */
86
+ export function siteBase(config: Pick<DocsConfig, "base">): string {
87
+ return config.base ? config.base.replace(/\/+$/, "") : ""
88
+ }
89
+
90
+ /**
91
+ * Chemin public d'un chemin du site : `/guides/publier/` → `/docs/guides/publier/` sous
92
+ * `base: "/docs"`. Un chemin qui porte déjà le sous-chemin, ou qui n'est pas absolu
93
+ * (`#ancre`, `https://…`, `//hôte`), reste tel quel.
94
+ */
95
+ export function withBase(base: string, path: string): string {
96
+ if (!base || !path.startsWith("/") || path.startsWith("//")) return path
97
+ if (path === base || path.startsWith(`${base}/`) || path.startsWith(`${base}#`) || path.startsWith(`${base}?`))
98
+ return path
99
+ return `${base}${path}`
100
+ }
101
+
102
+ /** Chemin du site sans son sous-chemin : `/docs/guides/` → `/guides/`, `/docs/` → `/`. */
103
+ export function withoutBase(base: string, pathname: string): string {
104
+ if (!base) return pathname
105
+ if (pathname === base) return "/"
106
+ return pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname
107
+ }
108
+
85
109
  /** Pages citées dans la navigation mais absentes du dossier : erreur de build lisible. */
86
110
  export function missingPages(tree: NavTree, existing: ReadonlySet<string>): Array<string> {
87
111
  return [...new Set(allPages(tree))].filter((page) => !existing.has(pageToSlug(page)))