@hgdc/agents 0.0.0-stage → 0.1.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/README.md +50 -2
- package/dist/build.d.ts +41 -0
- package/dist/build.js +113 -0
- package/dist/fetch.d.ts +19 -0
- package/dist/fetch.js +50 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/markdown.d.ts +7 -0
- package/dist/markdown.js +266 -0
- package/dist/negotiate.d.ts +8 -0
- package/dist/negotiate.js +32 -0
- package/package.json +33 -4
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hgdc/agents
|
|
2
2
|
|
|
3
|
-
|
|
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 |
|
package/dist/build.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/fetch.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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;
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
hasClass(el, /(?:^|\s)(?:visually-hidden|sr-only|skip-link)(?:\s|$)/);
|
|
104
|
+
const hasBlock = (node) => typeof node !== "string" && !hidden(node) && (!INLINE.has(node.tag) || node.children.some(hasBlock));
|
|
105
|
+
// Espaces ordinaires seulement : U+00A0 et U+202F (espace fine) sont des choix typographiques.
|
|
106
|
+
const collapse = (text) => text.replace(/[ \t\n\r\f]+/g, " ");
|
|
107
|
+
const escape = (text) => text.replace(/([\\*_`[\]])/g, "\\$1");
|
|
108
|
+
function inline(nodes, link) {
|
|
109
|
+
let out = "";
|
|
110
|
+
let afterElement = false;
|
|
111
|
+
for (const node of nodes) {
|
|
112
|
+
if (typeof node === "string") {
|
|
113
|
+
out += escape(collapse(node));
|
|
114
|
+
afterElement = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (hidden(node))
|
|
118
|
+
continue;
|
|
119
|
+
// Deux éléments collés (boutons, badges côte à côte) : séparés par une espace, comme à l'écran.
|
|
120
|
+
if (afterElement && node.tag !== "br" && out && !/\s$/.test(out))
|
|
121
|
+
out += " ";
|
|
122
|
+
afterElement = true;
|
|
123
|
+
const inner = () => inline(node.children, link);
|
|
124
|
+
switch (node.tag) {
|
|
125
|
+
case "br":
|
|
126
|
+
// Saut de ligne forcé de CommonMark : barre oblique inverse en fin de ligne.
|
|
127
|
+
out += "\\\n";
|
|
128
|
+
break;
|
|
129
|
+
case "strong":
|
|
130
|
+
case "b": {
|
|
131
|
+
const text = inner().trim();
|
|
132
|
+
if (text)
|
|
133
|
+
out += `**${text}**`;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "em":
|
|
137
|
+
case "i": {
|
|
138
|
+
const text = inner().trim();
|
|
139
|
+
if (text)
|
|
140
|
+
out += `_${text}_`;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "code":
|
|
144
|
+
out += `\`${inner().replace(/\\([\\*_`[\]])/g, "$1")}\``;
|
|
145
|
+
break;
|
|
146
|
+
case "img":
|
|
147
|
+
// L'image en double pour le thème sombre (only-dark) ne se lit qu'une fois.
|
|
148
|
+
if (node.attrs.alt && !hasClass(node, /(?:^|\s)only-dark(?:\s|$)/))
|
|
149
|
+
out += escape(node.attrs.alt);
|
|
150
|
+
break;
|
|
151
|
+
case "a": {
|
|
152
|
+
const text = inner().trim();
|
|
153
|
+
const href = node.attrs.href ? link(node.attrs.href) : null;
|
|
154
|
+
out += href && text ? `[${text}](${href})` : text;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
out += inner();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
/** Bloc de texte propre : espaces fusionnés, bords retirés, sauts de ligne forcés gardés. */
|
|
164
|
+
const tidy = (text) => text
|
|
165
|
+
.split("\n")
|
|
166
|
+
.map((line) => line.replace(/ {2,}/g, " ").trim())
|
|
167
|
+
.join("\n")
|
|
168
|
+
.trim()
|
|
169
|
+
.replace(/\\$/, "");
|
|
170
|
+
function blocks(el, link) {
|
|
171
|
+
const out = [];
|
|
172
|
+
let run = [];
|
|
173
|
+
const flush = () => {
|
|
174
|
+
const text = tidy(inline(run, link));
|
|
175
|
+
if (text)
|
|
176
|
+
out.push(text);
|
|
177
|
+
run = [];
|
|
178
|
+
};
|
|
179
|
+
for (const node of el.children) {
|
|
180
|
+
if (!hasBlock(node)) {
|
|
181
|
+
run.push(node);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
flush();
|
|
185
|
+
out.push(...block(node, link));
|
|
186
|
+
}
|
|
187
|
+
flush();
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function block(el, link) {
|
|
191
|
+
const text = () => tidy(inline(el.children, link)).replace(/\n/g, " ");
|
|
192
|
+
const heading = /^h([1-6])$/.exec(el.tag);
|
|
193
|
+
if (heading) {
|
|
194
|
+
const t = text();
|
|
195
|
+
return t ? [`${"#".repeat(Number(heading[1]))} ${t}`] : [];
|
|
196
|
+
}
|
|
197
|
+
switch (el.tag) {
|
|
198
|
+
case "hr":
|
|
199
|
+
return ["---"];
|
|
200
|
+
case "ul":
|
|
201
|
+
case "ol": {
|
|
202
|
+
const items = el.children.filter((c) => typeof c !== "string" && c.tag === "li" && !hidden(c));
|
|
203
|
+
const lines = items.map((li, i) => {
|
|
204
|
+
const marker = el.tag === "ol" ? `${i + 1}. ` : "- ";
|
|
205
|
+
const body = blocks(li, link).join("\n\n");
|
|
206
|
+
return marker + body.replace(/\n(?!\n)/g, `\n${" ".repeat(marker.length)}`);
|
|
207
|
+
});
|
|
208
|
+
return lines.length ? [lines.join("\n")] : [];
|
|
209
|
+
}
|
|
210
|
+
case "blockquote":
|
|
211
|
+
return blocks(el, link).map((b) => b.replace(/^/gm, "> "));
|
|
212
|
+
case "dt":
|
|
213
|
+
case "summary": {
|
|
214
|
+
const t = text();
|
|
215
|
+
return t ? [`**${t}**`] : [];
|
|
216
|
+
}
|
|
217
|
+
case "a": {
|
|
218
|
+
// Lien qui enveloppe des blocs (carte) : le contenu, puis le lien seul.
|
|
219
|
+
const href = el.attrs.href ? link(el.attrs.href) : null;
|
|
220
|
+
const inner = blocks(el, link);
|
|
221
|
+
return href ? [...inner, `[${inner[0]?.replace(/^#+ /, "") ?? href}](${href})`] : inner;
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
return blocks(el, link);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Contenu de la page : le premier `<main>`, sinon le `<body>`, sinon tout le fragment. */
|
|
228
|
+
function pageContent(html) {
|
|
229
|
+
const main = /<main\b[^>]*>([\s\S]*?)<\/main>/i.exec(html);
|
|
230
|
+
if (main)
|
|
231
|
+
return main[1];
|
|
232
|
+
const body = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(html);
|
|
233
|
+
return body ? body[1] : html;
|
|
234
|
+
}
|
|
235
|
+
/** Titre de la page (`<title>`), pour une page dont le contenu n'a pas de titre de niveau 1. */
|
|
236
|
+
export function pageTitle(html) {
|
|
237
|
+
const title = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
238
|
+
return title ? collapse(decode(title[1])).trim() || null : null;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Markdown du contenu d'une page HTML.
|
|
242
|
+
* @param base adresse de la page, pour rendre les liens absolus
|
|
243
|
+
*/
|
|
244
|
+
export function htmlToMarkdown(html, base) {
|
|
245
|
+
const page = new URL(base);
|
|
246
|
+
const link = (href) => {
|
|
247
|
+
if (href.startsWith("#"))
|
|
248
|
+
return null;
|
|
249
|
+
if (/^(?:mailto|tel):/i.test(href))
|
|
250
|
+
return href;
|
|
251
|
+
try {
|
|
252
|
+
const url = new URL(href, page);
|
|
253
|
+
return /^https?:$/.test(url.protocol) ? url.href : null;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const out = blocks(parse(pageContent(html)), link);
|
|
260
|
+
if (!out.some((b) => b.startsWith("# "))) {
|
|
261
|
+
const title = pageTitle(html);
|
|
262
|
+
if (title)
|
|
263
|
+
out.unshift(`# ${escape(title)}`);
|
|
264
|
+
}
|
|
265
|
+
return `${out.join("\n\n")}\n`;
|
|
266
|
+
}
|
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
}
|