@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
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
2
2
  import { dirname, join, relative, resolve } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
 
@@ -7,10 +7,18 @@ import starlight from "@astrojs/starlight"
7
7
  import type { AstroIntegration, AstroUserConfig } from "astro"
8
8
 
9
9
  import { remarkAutoImport } from "./build/auto-import.ts"
10
+ import { remarkBaseLinks } from "./build/base-links.ts"
10
11
  import { publicFiles } from "./build/public-files.ts"
11
12
  import { writeMachineReadable } from "./build/outputs.ts"
12
- import { parseDocsConfig, withHostProject, type DocsConfig } from "./core/config.ts"
13
- import { missingPages, normalizeNavigation, pageToSlug, type NavItem, type NavTree } from "./core/navigation.ts"
13
+ import { parseDocsConfig, socialNetwork, withHostProject, type DocsConfig } from "./core/config.ts"
14
+ import {
15
+ missingPages,
16
+ normalizeNavigation,
17
+ pageToSlug,
18
+ siteBase,
19
+ type NavItem,
20
+ type NavTree,
21
+ } from "./core/navigation.ts"
14
22
  import { themeCss, themeFor } from "./core/theme.ts"
15
23
 
16
24
  /** Où lire la doc du client et où écrire le site : posé par la CLI `hgdc-docs`. */
@@ -35,12 +43,27 @@ export function readSiteOptions(env: NodeJS.ProcessEnv = process.env): SiteOptio
35
43
  return {
36
44
  contentDir,
37
45
  outDir: resolve(env.HGDC_DOCS_OUT ?? join(contentDir, "dist")),
38
- site: env.HGDC_DOCS_SITE ?? vercelUrl,
46
+ site: siteOrigin(env.HGDC_DOCS_SITE ?? vercelUrl),
39
47
  apiEndpoint: env.HGDC_DOCS_API ?? DEFAULT_API,
40
48
  project: env.HGDC_DOCS_PROJECT ?? null,
41
49
  }
42
50
  }
43
51
 
52
+ /**
53
+ * Origine du site seulement : le sous-chemin vient de `base` dans docs.json, pas de
54
+ * l'adresse (`https://app.ch/docs` et `https://app.ch` donnent le même site).
55
+ */
56
+ function siteOrigin(value: string | null): string | null {
57
+ if (!value) return null
58
+ try {
59
+ return new URL(value).origin
60
+ } catch {
61
+ throw new DocsConfigError(
62
+ `HGDC_DOCS_SITE : adresse invalide (${value.slice(0, 100)}). Exemple : https://docs.exemple.ch`,
63
+ )
64
+ }
65
+ }
66
+
44
67
  export class DocsConfigError extends Error {}
45
68
 
46
69
  export function loadDocsConfig(contentDir: string, hostProject: string | null = null): DocsConfig {
@@ -58,6 +81,8 @@ export type VirtualConfig = {
58
81
  tree: NavTree
59
82
  apiEndpoint: string
60
83
  site: string | null
84
+ /** Sous-chemin du site (`/docs`), `""` à la racine. */
85
+ base: string
61
86
  }
62
87
 
63
88
  const IGNORED_DIRS = new Set(["node_modules", "dist", ".hgdc-docs", ".git", ".github", ".vercel"])
@@ -123,17 +148,27 @@ export function starlightSidebar(
123
148
  )
124
149
  }
125
150
 
126
- const SOCIAL_ICONS = new Set([
127
- "github",
128
- "gitlab",
129
- "linkedin",
130
- "x.com",
131
- "mastodon",
132
- "discord",
133
- "youtube",
134
- "instagram",
135
- "bluesky",
136
- ])
151
+ /**
152
+ * `redirects` de docs.json en redirections d'Astro : une page qui renvoie à la nouvelle
153
+ * adresse (le site est statique). Chemins écrits comme les liens de la doc, `base` ajouté.
154
+ */
155
+ export function astroRedirects(
156
+ redirects: ReadonlyArray<{ source: string; destination: string; permanent?: boolean | undefined }>,
157
+ base: string,
158
+ ): Record<string, { status: 301 | 302; destination: string }> {
159
+ const slash = (path: string) => (path.endsWith("/") ? path : `${path}/`)
160
+ return Object.fromEntries(
161
+ redirects.map((item) => [
162
+ slash(item.source),
163
+ {
164
+ status: item.permanent === false ? 302 : 301,
165
+ destination: item.destination.startsWith("/")
166
+ ? `${base}${slash(item.destination.split("#")[0]!)}${item.destination.includes("#") ? `#${item.destination.split("#")[1]}` : ""}`
167
+ : item.destination,
168
+ },
169
+ ]),
170
+ )
171
+ }
137
172
 
138
173
  function virtualConfigPlugin(data: VirtualConfig) {
139
174
  const id = "virtual:hgdc-docs/config"
@@ -144,6 +179,10 @@ function virtualConfigPlugin(data: VirtualConfig) {
144
179
  }
145
180
  }
146
181
 
182
+ /** Site sous un sous-chemin : la racine renvoie vers la doc, pour une visite directe de l'hôte. */
183
+ const rootRedirect = (base: string) =>
184
+ `<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><meta http-equiv="refresh" content="0; url=${base}/"><link rel="canonical" href="${base}/"><a href="${base}/">Documentation</a>\n`
185
+
147
186
  function machineReadableOutputs(data: VirtualConfig, options: SiteOptions): AstroIntegration {
148
187
  return {
149
188
  name: "hgdc-docs-outputs",
@@ -155,12 +194,17 @@ function machineReadableOutputs(data: VirtualConfig, options: SiteOptions): Astr
155
194
  config: data.config,
156
195
  tree: data.tree,
157
196
  site: options.site,
197
+ base: data.base,
158
198
  })
159
199
  logger.info(`llms.txt, llms-full.txt, ${written.pages} pages Markdown et le corpus de l'assistant écrits.`)
160
200
  // Le build tourne dans le paquet, où ses dépendances se résolvent quel que soit le
161
201
  // gestionnaire du client (npm, pnpm, bun isolé), puis le site seul est copié chez lui.
202
+ // Sous un sous-chemin, le site va dans `dist/<base>/` : servi tel quel par Host, il
203
+ // répond aux mêmes adresses que derrière le proxy du produit (`/docs/_astro/…`).
162
204
  rmSync(options.outDir, { recursive: true, force: true })
163
- cpSync(fileURLToPath(dir), options.outDir, { recursive: true, filter: (path) => !path.includes(".prerender") })
205
+ const target = join(options.outDir, ...data.base.split("/").filter(Boolean))
206
+ cpSync(fileURLToPath(dir), target, { recursive: true, filter: (path) => !path.includes(".prerender") })
207
+ if (data.base) writeFileSync(join(options.outDir, "index.html"), rootRedirect(data.base))
164
208
  logger.info(`Site écrit dans ${options.outDir}`)
165
209
  },
166
210
  },
@@ -176,13 +220,15 @@ export function createAstroConfig(options: SiteOptions = readSiteOptions()): Ast
176
220
  throw new DocsConfigError(
177
221
  `docs.json cite des pages absentes du dossier :\n- ${missing.map((page) => `${page}.mdx`).join("\n- ")}`,
178
222
  )
179
- const data: VirtualConfig = { config, tree, apiEndpoint: options.apiEndpoint, site: options.site }
223
+ const base = siteBase(config)
224
+ const data: VirtualConfig = { config, tree, apiEndpoint: options.apiEndpoint, site: options.site, base }
180
225
  const theme = themeFor(config.colors?.primary, config.colors?.dark)
181
226
  const logo = typeof config.logo === "string" ? { light: config.logo } : config.logo
182
227
  const asset = (path: string) => join(options.contentDir, path.replace(/^\//, ""))
183
- const social = Object.entries(config.footer?.socials ?? {})
184
- .filter(([icon]) => SOCIAL_ICONS.has(icon))
185
- .map(([icon, href]) => ({ icon: (icon === "x.com" ? "x.com" : icon) as "github", label: icon, href }))
228
+ const social = Object.entries(config.footer?.socials ?? {}).flatMap(([key, href]) => {
229
+ const network = socialNetwork(key)
230
+ return network ? [{ icon: network.icon as "github", label: network.label, href }] : []
231
+ })
186
232
 
187
233
  return {
188
234
  root: file("../template"),
@@ -194,8 +240,10 @@ export function createAstroConfig(options: SiteOptions = readSiteOptions()): Ast
194
240
  outDir: file("../template/.build"),
195
241
  cacheDir: join(options.contentDir, ".hgdc-docs", "cache"),
196
242
  site: options.site ?? undefined,
243
+ ...(base ? { base } : {}),
197
244
  trailingSlash: "always",
198
- markdown: { processor: unified({ remarkPlugins: [remarkAutoImport] }) },
245
+ redirects: astroRedirects(config.redirects ?? [], base),
246
+ markdown: { processor: unified({ remarkPlugins: [remarkAutoImport, [remarkBaseLinks, { base }]] }) },
199
247
  vite: {
200
248
  plugins: [virtualConfigPlugin(data)],
201
249
  server: { fs: { allow: [options.contentDir, file("..")] } },
@@ -219,6 +267,8 @@ export function createAstroConfig(options: SiteOptions = readSiteOptions()): Ast
219
267
  Sidebar: file("./overrides/Sidebar.astro"),
220
268
  PageTitle: file("./overrides/PageTitle.astro"),
221
269
  PageFrame: file("./overrides/PageFrame.astro"),
270
+ SocialIcons: file("./overrides/SocialIcons.astro"),
271
+ Footer: file("./overrides/Footer.astro"),
222
272
  },
223
273
  expressiveCode: { themes: ["github-dark-default", "github-light-default"] },
224
274
  pagination: true,
@@ -0,0 +1,154 @@
1
+ ---
2
+ import Default from "@astrojs/starlight/components/Footer.astro"
3
+ import data from "virtual:hgdc-docs/config"
4
+
5
+ // « Cette page t'a aidé ? » au bas de chaque page, envoyé à la console (`/api/v1/feedback`).
6
+ // Sans projet relié, rien n'est affiché : il n'y a personne pour lire les réponses.
7
+ const endpoint = new URL("/api/v1/feedback", data.config.assistant?.endpoint ?? data.apiEndpoint).toString()
8
+ ---
9
+
10
+ {
11
+ data.config.project && (
12
+ <hgdc-feedback class="hgdc-feedback not-content" data-endpoint={endpoint} data-project={data.config.project} data-pagefind-ignore>
13
+ <div data-step="ask">
14
+ <p class="hgdc-feedback__question">Cette page t'a aidé ?</p>
15
+ <div class="hgdc-feedback__choices">
16
+ <button type="button" data-helpful="true">Oui</button>
17
+ <button type="button" data-helpful="false">Non</button>
18
+ </div>
19
+ </div>
20
+ <form data-step="comment" hidden>
21
+ <label for="hgdc-feedback-comment">Qu'est-ce qui manquait ou pourrait être plus clair ? (facultatif)</label>
22
+ <textarea id="hgdc-feedback-comment" name="comment" rows="3" maxlength="1000"></textarea>
23
+ <div class="hgdc-feedback__choices">
24
+ <button type="submit">Envoyer</button>
25
+ <button type="button" data-skip>Passer</button>
26
+ </div>
27
+ </form>
28
+ <p data-step="done" class="hgdc-feedback__done" role="status" hidden>Merci, c'est noté.</p>
29
+ </hgdc-feedback>
30
+ )
31
+ }
32
+ <Default />
33
+
34
+ <script>
35
+ class HgdcFeedback extends HTMLElement {
36
+ #helpful: boolean | null = null
37
+
38
+ connectedCallback() {
39
+ for (const button of this.querySelectorAll<HTMLButtonElement>("[data-helpful]"))
40
+ button.addEventListener("click", () => this.#choose(button.dataset.helpful === "true"))
41
+ const form = this.querySelector("form")!
42
+ form.addEventListener("submit", (event) => {
43
+ event.preventDefault()
44
+ const comment = new FormData(form).get("comment")
45
+ void this.#send(typeof comment === "string" ? comment.trim() : "")
46
+ })
47
+ this.querySelector("[data-skip]")?.addEventListener("click", () => void this.#send(""))
48
+ }
49
+
50
+ #show(step: "ask" | "comment" | "done") {
51
+ for (const element of this.querySelectorAll<HTMLElement>("[data-step]")) element.hidden = element.dataset.step !== step
52
+ if (step === "comment") this.querySelector("textarea")?.focus()
53
+ }
54
+
55
+ #choose(helpful: boolean) {
56
+ this.#helpful = helpful
57
+ // Un oui part tout de suite ; un non attend le commentaire (ou « Passer ») : un seul envoi par avis.
58
+ if (helpful) void this.#send("")
59
+ else this.#show("comment")
60
+ }
61
+
62
+ async #send(comment: string) {
63
+ if (this.#helpful === null) return
64
+ try {
65
+ await fetch(this.dataset.endpoint!, {
66
+ method: "POST",
67
+ headers: { "content-type": "application/json" },
68
+ body: JSON.stringify({
69
+ project: this.dataset.project,
70
+ page: location.pathname,
71
+ helpful: this.#helpful,
72
+ ...(comment ? { comment } : {}),
73
+ }),
74
+ keepalive: true,
75
+ })
76
+ } catch {
77
+ // Un retour perdu ne gêne pas la lecture : rien à afficher.
78
+ }
79
+ this.#show("done")
80
+ }
81
+ }
82
+ customElements.define("hgdc-feedback", HgdcFeedback)
83
+ </script>
84
+
85
+ <style>
86
+ .hgdc-feedback {
87
+ display: block;
88
+ margin-block: 2.5rem 1.5rem;
89
+ padding: 1rem 1.25rem;
90
+ border-radius: 1rem;
91
+ background: var(--sl-color-gray-6);
92
+ }
93
+ .hgdc-feedback__question,
94
+ .hgdc-feedback label {
95
+ margin: 0 0 0.6rem;
96
+ font-size: var(--sl-text-sm);
97
+ font-weight: 500;
98
+ color: var(--sl-color-white);
99
+ }
100
+ .hgdc-feedback [data-step="ask"] {
101
+ display: flex;
102
+ flex-wrap: wrap;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ gap: 0.75rem;
106
+ }
107
+ .hgdc-feedback [data-step="ask"] .hgdc-feedback__question {
108
+ margin: 0;
109
+ }
110
+ .hgdc-feedback [hidden] {
111
+ display: none !important;
112
+ }
113
+ .hgdc-feedback__choices {
114
+ display: flex;
115
+ gap: 0.5rem;
116
+ }
117
+ .hgdc-feedback button {
118
+ border: 1px solid var(--sl-color-gray-5);
119
+ background: var(--sl-color-black);
120
+ color: var(--sl-color-white);
121
+ border-radius: 999px;
122
+ padding: 0.35rem 1rem;
123
+ font: inherit;
124
+ font-size: var(--sl-text-sm);
125
+ cursor: pointer;
126
+ }
127
+ .hgdc-feedback button:hover {
128
+ border-color: var(--sl-color-accent);
129
+ }
130
+ .hgdc-feedback button[type="submit"] {
131
+ background: var(--sl-color-accent);
132
+ border-color: var(--sl-color-accent);
133
+ color: var(--hgdc-on-accent, #fff);
134
+ }
135
+ .hgdc-feedback form {
136
+ display: grid;
137
+ gap: 0.6rem;
138
+ }
139
+ .hgdc-feedback textarea {
140
+ width: 100%;
141
+ border-radius: 0.75rem;
142
+ border: 1px solid var(--sl-color-gray-5);
143
+ background: var(--sl-color-black);
144
+ color: var(--sl-color-white);
145
+ padding: 0.5rem 0.75rem;
146
+ font: inherit;
147
+ font-size: var(--sl-text-sm);
148
+ }
149
+ .hgdc-feedback__done {
150
+ margin: 0;
151
+ font-size: var(--sl-text-sm);
152
+ color: var(--sl-color-gray-2);
153
+ }
154
+ </style>
@@ -1,12 +1,14 @@
1
1
  ---
2
2
  import Default from "@astrojs/starlight/components/Head.astro"
3
+ import data from "virtual:hgdc-docs/config"
3
4
 
4
5
  import { markdownPath } from "../core/llms.ts"
6
+ import { withBase, withoutBase } from "../core/navigation.ts"
5
7
 
6
8
  // llmstxt.org : chaque page annonce sa version Markdown et l'index qui la décrit.
7
- const markdown = markdownPath(Astro.url.pathname)
9
+ const markdown = withBase(data.base, markdownPath(withoutBase(data.base, Astro.url.pathname)))
8
10
  ---
9
11
 
10
12
  <Default><slot /></Default>
11
13
  <link rel="alternate" type="text/markdown" href={markdown} />
12
- <link rel="describedby" type="text/markdown" href="/llms.txt" />
14
+ <link rel="describedby" type="text/markdown" href={withBase(data.base, "/llms.txt")} />
@@ -3,8 +3,9 @@ import Default from "@astrojs/starlight/components/PageTitle.astro"
3
3
  import data from "virtual:hgdc-docs/config"
4
4
 
5
5
  import { markdownPath } from "../core/llms.ts"
6
+ import { withBase, withoutBase } from "../core/navigation.ts"
6
7
 
7
- const markdown = markdownPath(Astro.url.pathname)
8
+ const markdown = withBase(data.base, markdownPath(withoutBase(data.base, Astro.url.pathname)))
8
9
  const assistant = Boolean(data.config.project) && data.config.assistant?.enabled !== false
9
10
  ---
10
11
 
@@ -29,7 +30,7 @@ const assistant = Boolean(data.config.project) && data.config.assistant?.enabled
29
30
  <li><a href={markdown}>Voir en Markdown</a></li>
30
31
  <li><a data-hgdc-open="claude" href="https://claude.ai/new" target="_blank" rel="noopener">Ouvrir dans Claude</a></li>
31
32
  <li><a data-hgdc-open="chatgpt" href="https://chatgpt.com/" target="_blank" rel="noopener">Ouvrir dans ChatGPT</a></li>
32
- <li><a href="/llms.txt">Index pour les agents (llms.txt)</a></li>
33
+ <li><a href={withBase(data.base, "/llms.txt")}>Index pour les agents (llms.txt)</a></li>
33
34
  </ul>
34
35
  </details>
35
36
  {
@@ -2,7 +2,7 @@
2
2
  import Default from "@astrojs/starlight/components/Sidebar.astro"
3
3
  import data from "virtual:hgdc-docs/config"
4
4
 
5
- import { pagesOf, pageToPath } from "../core/navigation.ts"
5
+ import { pagesOf, pageToPath, withBase } from "../core/navigation.ts"
6
6
 
7
7
  // Onglets et versions de docs.json, en tête de la sidebar : visibles sur ordinateur et dans
8
8
  // le menu mobile, sans toucher à la hauteur de l'en-tête de Starlight.
@@ -11,7 +11,7 @@ const index = Astro.locals.hgdcSection ?? 0
11
11
  const current = sections[index] ?? sections[0]!
12
12
  const firstPath = (items: Parameters<typeof pagesOf>[0]) => {
13
13
  const first = pagesOf(items)[0]
14
- return first ? pageToPath(first) : "/"
14
+ return withBase(data.base, first ? pageToPath(first) : "/")
15
15
  }
16
16
  const tabs = current.version.tabs.filter((tab) => tab.label)
17
17
  const versions = data.tree.versions.filter((version) => version.label)