@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.
- package/docs.schema.json +25 -0
- package/package.json +3 -1
- package/src/build/auto-import.ts +5 -24
- package/src/build/base-links.ts +35 -0
- package/src/build/outputs.ts +17 -4
- package/src/core/components.ts +166 -0
- package/src/core/config.ts +50 -0
- package/src/core/feedback-api.ts +17 -0
- package/src/core/llms.ts +8 -5
- package/src/core/navigation.ts +24 -0
- package/src/core/validate.ts +276 -0
- package/src/integration.ts +71 -21
- package/src/overrides/Footer.astro +154 -0
- package/src/overrides/Head.astro +4 -2
- package/src/overrides/PageTitle.astro +3 -2
- package/src/overrides/Sidebar.astro +2 -2
- package/src/overrides/SocialIcons.astro +59 -0
- package/template/.astro/content-assets.mjs +4 -1
- package/template/.build/.well-known/llms.txt +5 -5
- package/template/.build/404.html +5 -5
- package/template/.build/_astro/{common.8GHCkW7i.css → common.CQYUsl_5.css} +2 -2
- package/template/.build/_hgdc/corpus.json +1 -1
- package/template/.build/demarrer/premiere-doc/index.html +12 -12
- package/template/.build/demarrer/premiere-doc.md +1 -1
- package/template/.build/explications/donnees/index.html +11 -11
- package/template/.build/explications/donnees.md +1 -1
- package/template/.build/explications/fonctionnement/index.html +12 -12
- package/template/.build/explications/fonctionnement.md +3 -3
- package/template/.build/guides/agents/index.html +15 -10
- package/template/.build/guides/agents.md +15 -1
- package/template/.build/guides/assistant/index.html +14 -12
- package/template/.build/guides/assistant.md +5 -1
- package/template/.build/guides/composants/index.html +15 -15
- package/template/.build/guides/ecrire/index.html +12 -12
- package/template/.build/guides/ecrire.md +1 -1
- package/template/.build/guides/elements/index.html +1 -0
- package/template/.build/guides/navigation/index.html +11 -11
- package/template/.build/guides/publier/index.html +16 -16
- package/template/.build/guides/publier.md +8 -8
- package/template/.build/guides/theme/index.html +11 -11
- package/template/.build/guides/theme.md +1 -1
- package/template/.build/index.html +11 -11
- package/template/.build/index.md +5 -5
- package/template/.build/llms-full.txt +69 -47
- package/template/.build/llms.txt +5 -5
- package/template/.build/pagefind/fragment/fr-ch_2a431a9.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_35f556a.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_456586a.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_48ca71e.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_72b91f4.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_8f256dd.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_95a15cc.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_aa80c55.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_ab79738.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_c589c2a.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_dab8f5e.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_de60b43.pf_fragment +0 -0
- package/template/.build/pagefind/index/fr-ch_c5ced52.pf_index +0 -0
- package/template/.build/pagefind/pagefind-entry.json +1 -1
- package/template/.build/pagefind/pagefind.fr-ch_8a246aa922758.pf_meta +0 -0
- package/template/.build/reference/api-assistant/index.html +11 -11
- package/template/.build/reference/api-assistant.md +3 -3
- package/template/.build/reference/cli/index.html +11 -11
- package/template/.build/reference/cli.md +5 -5
- package/template/.build/reference/docs-json/index.html +35 -11
- package/template/.build/reference/docs-json.md +19 -15
- package/template/.build/reference/mcp/index.html +10 -10
- package/template/node_modules/.vite/deps/_metadata.json +8 -8
- package/template/.build/pagefind/fragment/fr-ch_424fd16.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_5a4d77d.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_6c36edc.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_73d7887.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_8234ed3.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_8f8a19e.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_9f1b981.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_a19039d.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_d72cc1e.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_d732215.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_e78d58b.pf_fragment +0 -0
- package/template/.build/pagefind/fragment/fr-ch_e92f972.pf_fragment +0 -0
- package/template/.build/pagefind/index/fr-ch_f94c95d.pf_index +0 -0
- 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 « < » 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
|
+
}
|
package/src/integration.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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>
|
package/src/overrides/Head.astro
CHANGED
|
@@ -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)
|