@hgdc/agents 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # @hgdc/agents
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Rendre un site lisible par les agents IA : chaque page existe en Markdown, servie quand la requête accepte `text/markdown` ([acceptmarkdown.com](https://acceptmarkdown.com)), avec `Vary: Accept`. Une page introuvable reste en 404, en Markdown pour un agent. Le HTML servi aux navigateurs ne change pas.
4
+
5
+ Sans dépendance. Le convertisseur vise le HTML qu'un produit rend lui-même (Astro, React SSR) : il lit le `<main>` (sinon le `<body>`) et laisse de côté ce qui est caché aux lecteurs d'écran (`aria-hidden`, `visually-hidden`, `hidden`), les scripts, les styles, les SVG et les formulaires.
6
+
7
+ ## App rendue côté serveur (TanStack Start)
8
+
9
+ TanStack Start répond 406 à une requête sans `text/html` ni `*/*`. Le point d'entrée serveur enrobe donc son handler, avant le routeur :
10
+
11
+ ```ts
12
+ // src/server.ts
13
+ import handler, { createServerEntry } from "@tanstack/react-start/server-entry"
14
+ import { linksFooter, withMarkdown } from "@hgdc/agents"
15
+
16
+ const fetch = withMarkdown(handler.fetch, {
17
+ footer: linksFooter([{ label: "Résumé pour les agents", href: "https://app.hgdc.ch/mail/llms.txt" }]),
18
+ })
19
+
20
+ export default createServerEntry({ fetch })
21
+ ```
22
+
23
+ Seuls les GET et HEAD qui préfèrent le Markdown sont convertis (`prefersMarkdown` : `text/markdown` accepté avec une qualité au moins égale à `text/html` ; un joker ne compte pas). Une redirection, un fichier ou du JSON passent tels quels.
24
+
25
+ ## Site prérendu (Astro, sortie Build Output API)
26
+
27
+ À la fin du build, `@hgdc/agents/build` écrit `<page>.md` à côté de chaque page et donne les routes qui les servent, sur HGDC Host comme sur Vercel :
28
+
29
+ ```js
30
+ import { markdownRoutes, writeMarkdownPages } from "@hgdc/agents/build"
31
+
32
+ // astro:build:done ({ dir }), après l'adaptateur Vercel qui a écrit .vercel/output/config.json
33
+ const pages = await writeMarkdownPages(dir, { site: "https://hgdc.ch", footer })
34
+ const { before, miss } = markdownRoutes(pages, { site: "https://hgdc.ch" })
35
+ config.routes.unshift(...before) // avant { handle: "filesystem" }
36
+ config.routes.splice(-1, 0, ...miss) // juste avant la page 404 de l'adaptateur
37
+ ```
38
+
39
+ `base` (`/agence`) sert un site publié sous un chemin : son accueil s'écrit `<base>/index.md` et sa 404 Markdown reste sous le préfixe.
40
+
41
+ ## Référence
42
+
43
+ | Export | Rôle |
44
+ | --------------------------------------------- | ---------------------------------------------------- |
45
+ | `htmlToMarkdown(html, url)` | Markdown du contenu d'une page, liens rendus absolus |
46
+ | `prefersMarkdown(accept)` | La requête préfère-t-elle le Markdown ? |
47
+ | `withMarkdown(fetch, { footer })` | Handler `fetch` qui sert le Markdown aux agents |
48
+ | `linksFooter(links)` | Pied de page : liens séparés par « · » |
49
+ | `varyOnAccept(headers)` | Ajoute `Accept` à `Vary` |
50
+ | `writeMarkdownPages(dir, options)` (`/build`) | Écrit les `.md` d'un build prérendu |
51
+ | `markdownRoutes(pages, options)` (`/build`) | Routes Build Output API v3 qui les servent |
@@ -0,0 +1,41 @@
1
+ /** Route Build Output API v3, sous-ensemble utilisé ici. */
2
+ export type Route = {
3
+ src: string;
4
+ dest?: string;
5
+ status?: number;
6
+ headers?: Record<string, string>;
7
+ has?: {
8
+ type: "header";
9
+ key: string;
10
+ value: string;
11
+ }[];
12
+ continue?: boolean;
13
+ };
14
+ export type MarkdownPage = {
15
+ /** Chemin public de la page : `/`, `/contact`, `/agence/blog/article`. */
16
+ path: string;
17
+ /** Fichier Markdown écrit, relatif au dossier du build : `index.md`, `contact.md`. */
18
+ file: string;
19
+ };
20
+ export type MarkdownBuildOptions = {
21
+ /** Origine publique, sans barre finale : `https://hgdc.ch`. */
22
+ site: string;
23
+ /** Préfixe du site s'il est servi sous un chemin (`/agence`) ; sa page d'accueil s'écrit `<base>/index.md`. */
24
+ base?: string;
25
+ /** Pied de chaque page Markdown, après une ligne `---`. */
26
+ footer?: string;
27
+ };
28
+ /**
29
+ * Écrit `<page>.md` pour chaque page prérendue de `dir` (dossier rendu par Astro, `dist/` : l'adaptateur
30
+ * Vercel le copie ensuite vers `.vercel/output/static`), et `404.md` à côté de chaque `404.html`.
31
+ */
32
+ export declare function writeMarkdownPages(dir: URL, options: MarkdownBuildOptions): Promise<MarkdownPage[]>;
33
+ /**
34
+ * Routes à placer avant `{ handle: "filesystem" }` (`before`) et juste avant la page 404 de l'adaptateur
35
+ * (`miss`) : Markdown servi sur `Accept: text/markdown` avec `Vary: Accept` et l'adresse canonique en `Link`,
36
+ * `Vary: Accept` sur le HTML des mêmes pages, `404.md` (statut 404) pour une adresse inconnue.
37
+ */
38
+ export declare function markdownRoutes(pages: MarkdownPage[], options: Pick<MarkdownBuildOptions, "site" | "base">): {
39
+ before: Route[];
40
+ miss: Route[];
41
+ };
package/dist/build.js ADDED
@@ -0,0 +1,113 @@
1
+ // Sites prérendus (Astro + sortie Build Output API v3, servie par HGDC Host ou Vercel) : écrit la
2
+ // version Markdown de chaque page au build et produit les routes qui la servent sur
3
+ // `Accept: text/markdown`. Rien ne s'exécute à la requête : tout est fichier statique et route.
4
+ import { readdir, readFile, writeFile } from "node:fs/promises";
5
+ import { htmlToMarkdown } from "./markdown.js";
6
+ const NOT_FOUND = "404.html";
7
+ // Valeur de condition commune à Host (edge/src/router.ts) et Vercel : correspondance sur toute la valeur.
8
+ const ACCEPTS_MARKDOWN = { type: "header", key: "accept", value: ".*text/markdown.*" };
9
+ const MARKDOWN_TYPE = "text/markdown; charset=utf-8";
10
+ // Host refuse une expression de plus de 4096 caractères : les listes de pages se découpent en dessous.
11
+ const MAX_PATTERN = 3000;
12
+ const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
+ /** Chemin public d'un fichier HTML du build, `null` si ce n'est pas une page. */
14
+ function pagePath(file) {
15
+ if (file === "index.html")
16
+ return "/";
17
+ const m = /^(.+)\/index\.html$/.exec(file);
18
+ return m ? `/${m[1]}` : null;
19
+ }
20
+ /** Fichier Markdown d'une page : `index.md` pour la racine (ou le préfixe), `<chemin>.md` sinon. */
21
+ function markdownFile(path, base) {
22
+ if (path === "/")
23
+ return "index.md";
24
+ if (path === base)
25
+ return `${base.slice(1)}/index.md`;
26
+ return `${path.slice(1)}.md`;
27
+ }
28
+ /**
29
+ * Écrit `<page>.md` pour chaque page prérendue de `dir` (dossier rendu par Astro, `dist/` : l'adaptateur
30
+ * Vercel le copie ensuite vers `.vercel/output/static`), et `404.md` à côté de chaque `404.html`.
31
+ */
32
+ export async function writeMarkdownPages(dir, options) {
33
+ const base = options.base ?? "";
34
+ const footer = options.footer ? `\n---\n\n${options.footer}\n` : "";
35
+ const pages = [];
36
+ const files = (await readdir(dir, { recursive: true })).map((f) => f.replaceAll("\\", "/")).sort();
37
+ for (const file of files) {
38
+ const notFound = file === NOT_FOUND || file.endsWith(`/${NOT_FOUND}`);
39
+ const path = notFound ? null : pagePath(file);
40
+ if (!notFound && path === null)
41
+ continue;
42
+ const html = await readFile(new URL(file, dir), "utf-8");
43
+ const url = `${options.site}${path ?? `${base}/`}`;
44
+ const target = notFound ? file.replace(/\.html$/, ".md") : markdownFile(path, base);
45
+ await writeFile(new URL(target, dir), htmlToMarkdown(html, url) + footer);
46
+ if (path !== null)
47
+ pages.push({ path, file: target });
48
+ }
49
+ return pages;
50
+ }
51
+ /** Alternances `a|b|c` de moins de `MAX_PATTERN` caractères. */
52
+ function chunks(items) {
53
+ const out = [];
54
+ let current = [];
55
+ for (const item of items) {
56
+ if (current.length && [...current, item].join("|").length > MAX_PATTERN) {
57
+ out.push(current.join("|"));
58
+ current = [];
59
+ }
60
+ current.push(item);
61
+ }
62
+ if (current.length)
63
+ out.push(current.join("|"));
64
+ return out;
65
+ }
66
+ /**
67
+ * Routes à placer avant `{ handle: "filesystem" }` (`before`) et juste avant la page 404 de l'adaptateur
68
+ * (`miss`) : Markdown servi sur `Accept: text/markdown` avec `Vary: Accept` et l'adresse canonique en `Link`,
69
+ * `Vary: Accept` sur le HTML des mêmes pages, `404.md` (statut 404) pour une adresse inconnue.
70
+ */
71
+ export function markdownRoutes(pages, options) {
72
+ const base = options.base ?? "";
73
+ const canonical = (path) => `<${options.site}${path}>; rel="canonical"`;
74
+ const before = [];
75
+ // Pages dont le fichier ne suit pas `<chemin>.md` (racine, préfixe) : une route chacune.
76
+ const special = pages.filter((p) => p.file !== `${p.path.slice(1)}.md`);
77
+ const regular = pages.filter((p) => p.file === `${p.path.slice(1)}.md`);
78
+ for (const group of chunks(pages.map((p) => escapeRegex(p.path))))
79
+ before.push({ src: `^(?:${group})$`, headers: { Vary: "Accept" }, continue: true });
80
+ for (const page of special) {
81
+ const src = `^${escapeRegex(page.path)}$`;
82
+ const link = canonical(page.path === "/" ? "/" : page.path);
83
+ before.push({
84
+ src,
85
+ has: [ACCEPTS_MARKDOWN],
86
+ dest: `/${page.file}`,
87
+ headers: { "Content-Type": MARKDOWN_TYPE, Vary: "Accept", Link: link },
88
+ });
89
+ before.push({ src: `^/${escapeRegex(page.file)}$`, headers: { Link: link }, continue: true });
90
+ }
91
+ for (const group of chunks(regular.map((p) => escapeRegex(p.path.slice(1))))) {
92
+ const link = `<${options.site}/$1>; rel="canonical"`;
93
+ before.push({
94
+ src: `^/(${group})$`,
95
+ has: [ACCEPTS_MARKDOWN],
96
+ dest: "/$1.md",
97
+ headers: { "Content-Type": MARKDOWN_TYPE, Vary: "Accept", Link: link },
98
+ });
99
+ // Lue directement (/contact.md), la version Markdown renvoie à la page HTML de référence.
100
+ before.push({ src: `^/(${group})\\.md$`, headers: { Link: link }, continue: true });
101
+ }
102
+ const scope = base ? `^${escapeRegex(base)}(?:/.*)?$` : "^/.*$";
103
+ const miss = [
104
+ {
105
+ src: scope,
106
+ has: [ACCEPTS_MARKDOWN],
107
+ dest: `${base}/404.md`,
108
+ status: 404,
109
+ headers: { "Content-Type": MARKDOWN_TYPE, Vary: "Accept" },
110
+ },
111
+ ];
112
+ return { before, miss };
113
+ }
@@ -0,0 +1,19 @@
1
+ export type MarkdownOptions = {
2
+ /** Markdown ajouté en pied de chaque page, après une ligne `---` : liens vers llms.txt, sitemap, contact. */
3
+ footer?: string;
4
+ };
5
+ /** Pied de page standard : une ligne de liens, `[libellé](adresse)` séparés par « · ». */
6
+ export declare function linksFooter(links: {
7
+ label: string;
8
+ href: string;
9
+ }[]): string;
10
+ /**
11
+ * Enrobe un handler `fetch` : une requête GET ou HEAD qui préfère `text/markdown` reçoit la page HTML
12
+ * convertie, au même statut (404 compris), avec `Content-Type: text/markdown` et `Vary: Accept`. Les autres
13
+ * requêtes passent telles quelles ; leurs pages HTML reçoivent `Vary: Accept`. Une redirection, un fichier
14
+ * ou du JSON ne sont jamais convertis.
15
+ *
16
+ * TanStack Start refuse en 406 une requête sans `text/html` ni `*\/*` : le handler reçoit donc la requête
17
+ * avec `Accept: text/html`.
18
+ */
19
+ export declare function withMarkdown<Rest extends unknown[]>(handler: (request: Request, ...rest: Rest) => Response | Promise<Response>, options?: MarkdownOptions): (request: Request, ...rest: Rest) => Promise<Response>;
package/dist/fetch.js ADDED
@@ -0,0 +1,50 @@
1
+ // Version Markdown des pages d'une app rendue côté serveur (TanStack Start, ou tout handler `fetch`).
2
+ import { htmlToMarkdown } from "./markdown.js";
3
+ import { prefersMarkdown, varyOnAccept } from "./negotiate.js";
4
+ /** Pied de page standard : une ligne de liens, `[libellé](adresse)` séparés par « · ». */
5
+ export function linksFooter(links) {
6
+ return links.map((l) => `[${l.label}](${l.href})`).join(" · ");
7
+ }
8
+ const isHtml = (response) => /^text\/html\b/i.test(response.headers.get("content-type") ?? "");
9
+ // En-têtes qui décrivent le corps HTML et deviennent faux pour le Markdown.
10
+ const BODY_HEADERS = ["content-type", "content-length", "content-encoding", "etag", "last-modified", "content-md5"];
11
+ /**
12
+ * Enrobe un handler `fetch` : une requête GET ou HEAD qui préfère `text/markdown` reçoit la page HTML
13
+ * convertie, au même statut (404 compris), avec `Content-Type: text/markdown` et `Vary: Accept`. Les autres
14
+ * requêtes passent telles quelles ; leurs pages HTML reçoivent `Vary: Accept`. Une redirection, un fichier
15
+ * ou du JSON ne sont jamais convertis.
16
+ *
17
+ * TanStack Start refuse en 406 une requête sans `text/html` ni `*\/*` : le handler reçoit donc la requête
18
+ * avec `Accept: text/html`.
19
+ */
20
+ export function withMarkdown(handler, options = {}) {
21
+ return async (request, ...rest) => {
22
+ const read = request.method === "GET" || request.method === "HEAD";
23
+ if (!read || !prefersMarkdown(request.headers.get("accept"))) {
24
+ const response = await handler(request, ...rest);
25
+ if (read && isHtml(response))
26
+ return withHeaders(response, response.body, varyOnAccept);
27
+ return response;
28
+ }
29
+ const headers = new Headers(request.headers);
30
+ headers.set("accept", "text/html");
31
+ // Requête reconstruite depuis l'URL : le Request de Nitro (srvx) n'est pas celui d'undici, et
32
+ // `new Request(request, …)` le refuse. HEAD est rendu en GET, sinon le corps à convertir serait vide.
33
+ const response = await handler(new Request(request.url, { method: "GET", headers, signal: request.signal }), ...rest);
34
+ if (!isHtml(response) || response.status < 200 || (response.status >= 300 && response.status < 400))
35
+ return withHeaders(response, request.method === "HEAD" ? null : response.body, varyOnAccept);
36
+ const html = await response.text();
37
+ const markdown = htmlToMarkdown(html, request.url) + (options.footer ? `\n---\n\n${options.footer}\n` : "");
38
+ return withHeaders(response, request.method === "HEAD" ? null : markdown, (out) => {
39
+ for (const name of BODY_HEADERS)
40
+ out.delete(name);
41
+ out.set("content-type", "text/markdown; charset=utf-8");
42
+ varyOnAccept(out);
43
+ });
44
+ };
45
+ }
46
+ function withHeaders(response, body, edit) {
47
+ const headers = new Headers(response.headers);
48
+ edit(headers);
49
+ return new Response(body, { status: response.status, statusText: response.statusText, headers });
50
+ }
@@ -0,0 +1,3 @@
1
+ export { htmlToMarkdown, pageTitle } from "./markdown.js";
2
+ export { prefersMarkdown, varyOnAccept } from "./negotiate.js";
3
+ export { linksFooter, withMarkdown, type MarkdownOptions } from "./fetch.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { htmlToMarkdown, pageTitle } from "./markdown.js";
2
+ export { prefersMarkdown, varyOnAccept } from "./negotiate.js";
3
+ export { linksFooter, withMarkdown } from "./fetch.js";
@@ -0,0 +1,7 @@
1
+ /** Titre de la page (`<title>`), pour une page dont le contenu n'a pas de titre de niveau 1. */
2
+ export declare function pageTitle(html: string): string | null;
3
+ /**
4
+ * Markdown du contenu d'une page HTML.
5
+ * @param base adresse de la page, pour rendre les liens absolus
6
+ */
7
+ export declare function htmlToMarkdown(html: string, base: string | URL): string;
@@ -0,0 +1,268 @@
1
+ // HTML → Markdown pour les agents. Prévu pour le HTML qu'un produit rend lui-même (Astro, React SSR),
2
+ // bien formé et sans contenu tiers : pas de dépendance, seulement les éléments qu'un site de la
3
+ // famille emploie. Ce qui est caché aux lecteurs d'écran (aria-hidden, visually-hidden, hidden)
4
+ // l'est aussi aux agents.
5
+ const VOID = new Set([
6
+ "area",
7
+ "base",
8
+ "br",
9
+ "col",
10
+ "embed",
11
+ "hr",
12
+ "img",
13
+ "input",
14
+ "link",
15
+ "meta",
16
+ "source",
17
+ "track",
18
+ "wbr",
19
+ ]);
20
+ const DROPPED = new Set([
21
+ "script",
22
+ "style",
23
+ "svg",
24
+ "template",
25
+ "noscript",
26
+ "button",
27
+ "form",
28
+ "input",
29
+ "select",
30
+ "textarea",
31
+ "dialog",
32
+ ]);
33
+ const INLINE = new Set([
34
+ "a",
35
+ "abbr",
36
+ "b",
37
+ "br",
38
+ "cite",
39
+ "code",
40
+ "em",
41
+ "i",
42
+ "img",
43
+ "kbd",
44
+ "label",
45
+ "mark",
46
+ "q",
47
+ "s",
48
+ "small",
49
+ "span",
50
+ "strong",
51
+ "sub",
52
+ "sup",
53
+ "time",
54
+ "u",
55
+ ]);
56
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
57
+ const decode = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name) => {
58
+ if (name[0] !== "#")
59
+ return ENTITIES[name.toLowerCase()] ?? whole;
60
+ const code = name[1] === "x" || name[1] === "X" ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
61
+ return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
62
+ });
63
+ function parseAttrs(source) {
64
+ const attrs = {};
65
+ for (const m of source.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g))
66
+ attrs[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? "");
67
+ return attrs;
68
+ }
69
+ /** Arbre minimal d'un fragment HTML. */
70
+ function parse(html) {
71
+ const root = { tag: "#root", attrs: {}, children: [] };
72
+ const stack = [root];
73
+ const top = () => stack[stack.length - 1];
74
+ const cleaned = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|svg|template)\b[\s\S]*?<\/\1>/gi, "");
75
+ let last = 0;
76
+ for (const m of cleaned.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g)) {
77
+ const text = cleaned.slice(last, m.index);
78
+ if (text)
79
+ top().children.push(decode(text));
80
+ last = m.index + m[0].length;
81
+ const tag = m[2].toLowerCase();
82
+ if (m[1]) {
83
+ // Fermeture : remonte jusqu'à l'élément ouvert correspondant (tolère un oubli).
84
+ const at = stack.findLastIndex((el) => el.tag === tag);
85
+ if (at > 0)
86
+ stack.length = at;
87
+ continue;
88
+ }
89
+ const el = { tag, attrs: parseAttrs(m[3]), children: [] };
90
+ top().children.push(el);
91
+ if (!VOID.has(tag) && !m[3].trimEnd().endsWith("/"))
92
+ stack.push(el);
93
+ }
94
+ const tail = cleaned.slice(last);
95
+ if (tail)
96
+ top().children.push(decode(tail));
97
+ return root;
98
+ }
99
+ const hasClass = (el, pattern) => pattern.test(el.attrs.class ?? "");
100
+ const hidden = (el) => DROPPED.has(el.tag) ||
101
+ el.attrs["aria-hidden"] === "true" ||
102
+ "hidden" in el.attrs ||
103
+ // `hidden` seul est la classe Tailwind `display: none` : l'autre moitié d'une image claire/sombre
104
+ // (`hidden dark:block`). `dark:hidden` ou `sm:hidden` ne comptent pas.
105
+ hasClass(el, /(?:^|\s)(?:visually-hidden|sr-only|skip-link|hidden)(?:\s|$)/);
106
+ const hasBlock = (node) => typeof node !== "string" && !hidden(node) && (!INLINE.has(node.tag) || node.children.some(hasBlock));
107
+ // Espaces ordinaires seulement : U+00A0 et U+202F (espace fine) sont des choix typographiques.
108
+ const collapse = (text) => text.replace(/[ \t\n\r\f]+/g, " ");
109
+ const escape = (text) => text.replace(/([\\*_`[\]])/g, "\\$1");
110
+ function inline(nodes, link) {
111
+ let out = "";
112
+ let afterElement = false;
113
+ for (const node of nodes) {
114
+ if (typeof node === "string") {
115
+ out += escape(collapse(node));
116
+ afterElement = false;
117
+ continue;
118
+ }
119
+ if (hidden(node))
120
+ continue;
121
+ // Deux éléments collés (boutons, badges côte à côte) : séparés par une espace, comme à l'écran.
122
+ if (afterElement && node.tag !== "br" && out && !/\s$/.test(out))
123
+ out += " ";
124
+ afterElement = true;
125
+ const inner = () => inline(node.children, link);
126
+ switch (node.tag) {
127
+ case "br":
128
+ // Saut de ligne forcé de CommonMark : barre oblique inverse en fin de ligne.
129
+ out += "\\\n";
130
+ break;
131
+ case "strong":
132
+ case "b": {
133
+ const text = inner().trim();
134
+ if (text)
135
+ out += `**${text}**`;
136
+ break;
137
+ }
138
+ case "em":
139
+ case "i": {
140
+ const text = inner().trim();
141
+ if (text)
142
+ out += `_${text}_`;
143
+ break;
144
+ }
145
+ case "code":
146
+ out += `\`${inner().replace(/\\([\\*_`[\]])/g, "$1")}\``;
147
+ break;
148
+ case "img":
149
+ // L'image en double pour le thème sombre (only-dark) ne se lit qu'une fois.
150
+ if (node.attrs.alt && !hasClass(node, /(?:^|\s)only-dark(?:\s|$)/))
151
+ out += escape(node.attrs.alt);
152
+ break;
153
+ case "a": {
154
+ const text = inner().trim();
155
+ const href = node.attrs.href ? link(node.attrs.href) : null;
156
+ out += href && text ? `[${text}](${href})` : text;
157
+ break;
158
+ }
159
+ default:
160
+ out += inner();
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ /** Bloc de texte propre : espaces fusionnés, bords retirés, sauts de ligne forcés gardés. */
166
+ const tidy = (text) => text
167
+ .split("\n")
168
+ .map((line) => line.replace(/ {2,}/g, " ").trim())
169
+ .join("\n")
170
+ .trim()
171
+ .replace(/\\$/, "");
172
+ function blocks(el, link) {
173
+ const out = [];
174
+ let run = [];
175
+ const flush = () => {
176
+ const text = tidy(inline(run, link));
177
+ if (text)
178
+ out.push(text);
179
+ run = [];
180
+ };
181
+ for (const node of el.children) {
182
+ if (!hasBlock(node)) {
183
+ run.push(node);
184
+ continue;
185
+ }
186
+ flush();
187
+ out.push(...block(node, link));
188
+ }
189
+ flush();
190
+ return out;
191
+ }
192
+ function block(el, link) {
193
+ const text = () => tidy(inline(el.children, link)).replace(/\n/g, " ");
194
+ const heading = /^h([1-6])$/.exec(el.tag);
195
+ if (heading) {
196
+ const t = text();
197
+ return t ? [`${"#".repeat(Number(heading[1]))} ${t}`] : [];
198
+ }
199
+ switch (el.tag) {
200
+ case "hr":
201
+ return ["---"];
202
+ case "ul":
203
+ case "ol": {
204
+ const items = el.children.filter((c) => typeof c !== "string" && c.tag === "li" && !hidden(c));
205
+ const lines = items.map((li, i) => {
206
+ const marker = el.tag === "ol" ? `${i + 1}. ` : "- ";
207
+ const body = blocks(li, link).join("\n\n");
208
+ return marker + body.replace(/\n(?!\n)/g, `\n${" ".repeat(marker.length)}`);
209
+ });
210
+ return lines.length ? [lines.join("\n")] : [];
211
+ }
212
+ case "blockquote":
213
+ return blocks(el, link).map((b) => b.replace(/^/gm, "> "));
214
+ case "dt":
215
+ case "summary": {
216
+ const t = text();
217
+ return t ? [`**${t}**`] : [];
218
+ }
219
+ case "a": {
220
+ // Lien qui enveloppe des blocs (carte) : le contenu, puis le lien seul.
221
+ const href = el.attrs.href ? link(el.attrs.href) : null;
222
+ const inner = blocks(el, link);
223
+ return href ? [...inner, `[${inner[0]?.replace(/^#+ /, "") ?? href}](${href})`] : inner;
224
+ }
225
+ default:
226
+ return blocks(el, link);
227
+ }
228
+ }
229
+ /** Contenu de la page : le premier `<main>`, sinon le `<body>`, sinon tout le fragment. */
230
+ function pageContent(html) {
231
+ const main = /<main\b[^>]*>([\s\S]*?)<\/main>/i.exec(html);
232
+ if (main)
233
+ return main[1];
234
+ const body = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(html);
235
+ return body ? body[1] : html;
236
+ }
237
+ /** Titre de la page (`<title>`), pour une page dont le contenu n'a pas de titre de niveau 1. */
238
+ export function pageTitle(html) {
239
+ const title = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html);
240
+ return title ? collapse(decode(title[1])).trim() || null : null;
241
+ }
242
+ /**
243
+ * Markdown du contenu d'une page HTML.
244
+ * @param base adresse de la page, pour rendre les liens absolus
245
+ */
246
+ export function htmlToMarkdown(html, base) {
247
+ const page = new URL(base);
248
+ const link = (href) => {
249
+ if (href.startsWith("#"))
250
+ return null;
251
+ if (/^(?:mailto|tel):/i.test(href))
252
+ return href;
253
+ try {
254
+ const url = new URL(href, page);
255
+ return /^https?:$/.test(url.protocol) ? url.href : null;
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ };
261
+ const out = blocks(parse(pageContent(html)), link);
262
+ if (!out.some((b) => b.startsWith("# "))) {
263
+ const title = pageTitle(html);
264
+ if (title)
265
+ out.unshift(`# ${escape(title)}`);
266
+ }
267
+ return `${out.join("\n\n")}\n`;
268
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Vrai si la requête préfère le Markdown au HTML : `text/markdown` accepté (q > 0), avec une qualité au
3
+ * moins égale à celle de `text/html` demandé explicitement. Les jokers (`*\/*`, `text/*`) ne comptent pas :
4
+ * un navigateur ne reçoit jamais de Markdown.
5
+ */
6
+ export declare function prefersMarkdown(accept: string | null | undefined): boolean;
7
+ /** Ajoute `Accept` à l'en-tête `Vary` d'une réponse, sans doublon. */
8
+ export declare function varyOnAccept(headers: Headers): void;
@@ -0,0 +1,32 @@
1
+ // Négociation de contenu : un agent qui demande text/markdown le reçoit (acceptmarkdown.com).
2
+ function parseAccept(accept) {
3
+ return accept
4
+ .split(",")
5
+ .map((part) => {
6
+ const [type = "", ...params] = part.trim().toLowerCase().split(";");
7
+ const q = params.map((p) => /^\s*q\s*=\s*([\d.]+)\s*$/.exec(p)?.[1]).find((v) => v !== undefined);
8
+ return { type: type.trim(), q: q === undefined ? 1 : Math.min(1, Number(q) || 0) };
9
+ })
10
+ .filter((range) => range.type);
11
+ }
12
+ /**
13
+ * Vrai si la requête préfère le Markdown au HTML : `text/markdown` accepté (q > 0), avec une qualité au
14
+ * moins égale à celle de `text/html` demandé explicitement. Les jokers (`*\/*`, `text/*`) ne comptent pas :
15
+ * un navigateur ne reçoit jamais de Markdown.
16
+ */
17
+ export function prefersMarkdown(accept) {
18
+ if (!accept)
19
+ return false;
20
+ const ranges = parseAccept(accept);
21
+ const quality = (type) => Math.max(0, ...ranges.filter((r) => r.type === type).map((r) => r.q));
22
+ const markdown = quality("text/markdown");
23
+ return markdown > 0 && markdown >= quality("text/html");
24
+ }
25
+ /** Ajoute `Accept` à l'en-tête `Vary` d'une réponse, sans doublon. */
26
+ export function varyOnAccept(headers) {
27
+ const vary = headers.get("vary");
28
+ if (!vary)
29
+ headers.set("vary", "Accept");
30
+ else if (!vary.split(",").some((v) => ["accept", "*"].includes(v.trim().toLowerCase())))
31
+ headers.set("vary", `${vary}, Accept`);
32
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@hgdc/agents",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Sites lisibles par les agents IA : version Markdown des pages (Accept: text/markdown), 404 en Markdown, routes Build Output API pour les sites prérendus.",
5
+ "license": "UNLICENSED",
6
+ "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/agents#readme",
7
+ "bugs": "https://github.com/hugogebel-boop/hgdc-kit/security/advisories/new",
8
+ "type": "module",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/hugogebel-boop/hgdc-kit.git",
12
+ "directory": "packages/agents"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "sideEffects": false,
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./build": {
27
+ "types": "./dist/build.d.ts",
28
+ "default": "./dist/build.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.build.json"
34
+ }
35
+ }