@hgdc/agents 0.1.2 → 0.2.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 CHANGED
@@ -20,6 +20,22 @@ const fetch = withMarkdown(handler.fetch, {
20
20
  export default createServerEntry({ fetch })
21
21
  ```
22
22
 
23
+ Pour un site servi à la racine de son hôte (Status, Portal, Account), les options ajoutent ce que les agents vérifient avant de recommander un site :
24
+
25
+ ```ts
26
+ import { HGDC_TRUST_PAGES, productJsonLd, withMarkdown } from "@hgdc/agents"
27
+
28
+ withMarkdown(handler.fetch, {
29
+ footer,
30
+ redirects: HGDC_TRUST_PAGES, // /about, /contact, /privacy → pages de hgdc.ch (301)
31
+ jsonLd: productJsonLd({ name: "Status", url: "https://status.hgdc.ch", description: "…" }),
32
+ canonicalOrigin: "https://status.hgdc.ch", // <link rel="canonical"> des pages en 200
33
+ apiPrefix: "/api", // 404 sous /api en application/problem+json
34
+ })
35
+ ```
36
+
37
+ Le JSON-LD et l'adresse canonique s'insèrent en flux avant `</head>`, seulement si la page n'en porte pas déjà : le rendu SSR en streaming n'est pas retenu au-delà du `<head>`.
38
+
23
39
  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
40
 
25
41
  ## Site prérendu (Astro, sortie Build Output API)
@@ -40,12 +56,14 @@ config.routes.splice(-1, 0, ...miss) // juste avant la page 404 de l'adaptateur
40
56
 
41
57
  ## Référence
42
58
 
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 |
59
+ | Export | Rôle |
60
+ | ----------------------------------------------------------------- | --------------------------------------------------------- |
61
+ | `htmlToMarkdown(html, url)` | Markdown du contenu d'une page, liens rendus absolus |
62
+ | `prefersMarkdown(accept)` | La requête préfère-t-elle le Markdown ? |
63
+ | `withMarkdown(fetch, { footer })` | Handler `fetch` qui sert le Markdown aux agents |
64
+ | `linksFooter(links)` | Pied de page : liens séparés par « · » |
65
+ | `HGDC_TRUST_PAGES`, `HGDC_ORGANIZATION`, `productJsonLd(product)` | Pages de confiance et données structurées de l'entreprise |
66
+ | `injectIntoHead(body, extra)` | Insère des balises avant `</head>` d'un flux HTML |
67
+ | `varyOnAccept(headers)` | Ajoute `Accept` à `Vary` |
68
+ | `writeMarkdownPages(dir, options)` (`/build`) | Écrit les `.md` d'un build prérendu |
69
+ | `markdownRoutes(pages, options)` (`/build`) | Routes Build Output API v3 qui les servent |
package/dist/fetch.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  export type MarkdownOptions = {
2
2
  /** Markdown ajouté en pied de chaque page, après une ligne `---` : liens vers llms.txt, sitemap, contact. */
3
3
  footer?: string;
4
+ /** Redirections permanentes (301) d'un chemin exact vers une adresse : pages de confiance (`HGDC_TRUST_PAGES`). */
5
+ redirects?: Readonly<Record<string, string>>;
6
+ /** JSON-LD posé dans le `<head>` de chaque page HTML qui n'en porte pas (`productJsonLd`). */
7
+ jsonLd?: unknown;
8
+ /** Origine publique (`https://status.hgdc.ch`) : `<link rel="canonical">` sur chaque page HTML en 200 qui n'en a pas. */
9
+ canonicalOrigin?: string;
10
+ /** Préfixe d'API (`/api`) : une page introuvable sous ce préfixe devient une erreur JSON (RFC 9457). */
11
+ apiPrefix?: string;
12
+ /** Indication donnée dans ces erreurs JSON : où lire l'API. */
13
+ apiHint?: string;
4
14
  };
5
15
  /** Pied de page standard : une ligne de liens, `[libellé](adresse)` séparés par « · ». */
6
16
  export declare function linksFooter(links: {
@@ -10,8 +20,8 @@ export declare function linksFooter(links: {
10
20
  /**
11
21
  * Enrobe un handler `fetch` : une requête GET ou HEAD qui préfère `text/markdown` reçoit la page HTML
12
22
  * 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.
23
+ * requêtes passent telles quelles ; leurs pages HTML reçoivent `Vary: Accept`, et selon les options le
24
+ * JSON-LD et l'adresse canonique. Une redirection, un fichier ou du JSON ne sont jamais convertis.
15
25
  *
16
26
  * TanStack Start refuse en 406 une requête sans `text/html` ni `*\/*` : le handler reçoit donc la requête
17
27
  * avec `Accept: text/html`.
package/dist/fetch.js CHANGED
@@ -1,4 +1,6 @@
1
- // Version Markdown des pages d'une app rendue côté serveur (TanStack Start, ou tout handler `fetch`).
1
+ // Ce qu'une app rendue côté serveur (TanStack Start, ou tout handler `fetch`) montre aux agents : la page en
2
+ // Markdown, ses données structurées et son adresse canonique, les pages de confiance, des erreurs d'API en JSON.
3
+ import { canonicalLink, injectIntoHead, jsonLdScript } from "./head.js";
2
4
  import { htmlToMarkdown } from "./markdown.js";
3
5
  import { prefersMarkdown, varyOnAccept } from "./negotiate.js";
4
6
  /** Pied de page standard : une ligne de liens, `[libellé](adresse)` séparés par « · ». */
@@ -6,13 +8,13 @@ export function linksFooter(links) {
6
8
  return links.map((l) => `[${l.label}](${l.href})`).join(" · ");
7
9
  }
8
10
  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.
11
+ // En-têtes qui décrivent le corps HTML et deviennent faux pour un autre corps.
10
12
  const BODY_HEADERS = ["content-type", "content-length", "content-encoding", "etag", "last-modified", "content-md5"];
11
13
  /**
12
14
  * Enrobe un handler `fetch` : une requête GET ou HEAD qui préfère `text/markdown` reçoit la page HTML
13
15
  * 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
+ * requêtes passent telles quelles ; leurs pages HTML reçoivent `Vary: Accept`, et selon les options le
17
+ * JSON-LD et l'adresse canonique. Une redirection, un fichier ou du JSON ne sont jamais convertis.
16
18
  *
17
19
  * TanStack Start refuse en 406 une requête sans `text/html` ni `*\/*` : le handler reçoit donc la requête
18
20
  * avec `Accept: text/html`.
@@ -20,22 +22,26 @@ const BODY_HEADERS = ["content-type", "content-length", "content-encoding", "eta
20
22
  export function withMarkdown(handler, options = {}) {
21
23
  return async (request, ...rest) => {
22
24
  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);
25
+ const url = new URL(request.url);
26
+ const redirect = read ? options.redirects?.[url.pathname.replace(/(.)\/$/, "$1")] : undefined;
27
+ if (redirect)
28
+ return new Response(null, { status: 301, headers: { location: redirect } });
29
+ const markdown = read && prefersMarkdown(request.headers.get("accept"));
30
+ const response = markdown
31
+ ? await handler(
32
+ // Requête reconstruite depuis l'URL : le Request de Nitro (srvx) n'est pas celui d'undici, et
33
+ // `new Request(request, …)` le refuse. HEAD est rendu en GET, sinon le corps à convertir serait vide.
34
+ new Request(request.url, { method: "GET", headers: htmlAccept(request.headers), signal: request.signal }), ...rest)
35
+ : await handler(request, ...rest);
36
+ if (!read || !isHtml(response))
27
37
  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) => {
38
+ if (options.apiPrefix && response.status === 404 && isUnder(url.pathname, options.apiPrefix))
39
+ return apiNotFound(url.pathname, options.apiHint);
40
+ const redirected = response.status >= 300 && response.status < 400;
41
+ if (!markdown || response.status < 200 || redirected)
42
+ return withHeaders(response, request.method === "HEAD" ? null : enrichHead(response, url, options), varyOnAccept);
43
+ const text = htmlToMarkdown(await response.text(), request.url) + (options.footer ? `\n---\n\n${options.footer}\n` : "");
44
+ return withHeaders(response, request.method === "HEAD" ? null : text, (out) => {
39
45
  for (const name of BODY_HEADERS)
40
46
  out.delete(name);
41
47
  out.set("content-type", "text/markdown; charset=utf-8");
@@ -43,8 +49,47 @@ export function withMarkdown(handler, options = {}) {
43
49
  });
44
50
  };
45
51
  }
52
+ function htmlAccept(original) {
53
+ const headers = new Headers(original);
54
+ headers.set("accept", "text/html");
55
+ return headers;
56
+ }
57
+ const isUnder = (path, prefix) => path === prefix || path.startsWith(`${prefix.replace(/\/$/, "")}/`);
58
+ /** Corps HTML avec le JSON-LD et l'adresse canonique des options, s'il y a lieu. */
59
+ function enrichHead(response, url, options) {
60
+ const canonical = options.canonicalOrigin && response.status === 200 ? `${options.canonicalOrigin}${url.pathname}` : null;
61
+ if (!response.body || (!options.jsonLd && !canonical))
62
+ return response.body;
63
+ return injectIntoHead(response.body, (head) => {
64
+ let extra = "";
65
+ if (options.jsonLd && !/application\/ld\+json/i.test(head))
66
+ extra += jsonLdScript(options.jsonLd);
67
+ if (canonical && !/rel=["']?canonical/i.test(head))
68
+ extra += canonicalLink(canonical);
69
+ return extra;
70
+ });
71
+ }
72
+ /** Adresse inconnue sous l'API : erreur JSON au format RFC 9457 plutôt qu'une page HTML. */
73
+ function apiNotFound(path, hint) {
74
+ const problem = {
75
+ type: "about:blank",
76
+ title: "Ressource introuvable",
77
+ status: 404,
78
+ code: "not_found",
79
+ detail: `Aucune ressource à ${path}.`,
80
+ ...(hint ? { hint } : {}),
81
+ };
82
+ return new Response(JSON.stringify(problem), {
83
+ status: 404,
84
+ headers: { "content-type": "application/problem+json", vary: "Accept" },
85
+ });
86
+ }
46
87
  function withHeaders(response, body, edit) {
47
88
  const headers = new Headers(response.headers);
89
+ // Corps réécrit (Markdown, <head> enrichi) : longueur et empreinte d'origine ne valent plus.
90
+ if (body !== response.body)
91
+ for (const name of ["content-length", "etag", "content-md5"])
92
+ headers.delete(name);
48
93
  edit(headers);
49
94
  return new Response(body, { status: response.status, statusText: response.statusText, headers });
50
95
  }
package/dist/head.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Corps HTML avec `extra(head)` inséré juste avant `</head>`. `extra` reçoit le `<head>` déjà rendu, pour
3
+ * ne rien ajouter que la page porte déjà ; une chaîne vide n'ajoute rien.
4
+ */
5
+ export declare function injectIntoHead(body: ReadableStream<Uint8Array>, extra: (head: string) => string): ReadableStream<Uint8Array>;
6
+ /** JSON-LD sûr dans un `<script>` : `<` échappé, la balise ne peut pas se fermer avant la fin. */
7
+ export declare const jsonLdScript: (data: unknown) => string;
8
+ /** `<link rel="canonical">` pour une adresse absolue. */
9
+ export declare const canonicalLink: (href: string) => string;
package/dist/head.js ADDED
@@ -0,0 +1,37 @@
1
+ // Ajout de balises dans le <head> d'une page HTML servie en flux (rendu SSR en streaming) : le début du
2
+ // document est retenu jusqu'à `</head>`, la suite passe telle quelle.
3
+ /** Au-delà, la page n'a pas de `</head>` lisible : elle passe sans ajout. */
4
+ const MAX_HEAD = 256 * 1024;
5
+ /**
6
+ * Corps HTML avec `extra(head)` inséré juste avant `</head>`. `extra` reçoit le `<head>` déjà rendu, pour
7
+ * ne rien ajouter que la page porte déjà ; une chaîne vide n'ajoute rien.
8
+ */
9
+ export function injectIntoHead(body, extra) {
10
+ const decoder = new TextDecoder();
11
+ const encoder = new TextEncoder();
12
+ let buffer = "";
13
+ let done = false;
14
+ return body.pipeThrough(new TransformStream({
15
+ transform(chunk, controller) {
16
+ if (done)
17
+ return controller.enqueue(chunk);
18
+ buffer += decoder.decode(chunk, { stream: true });
19
+ const end = buffer.search(/<\/head>/i);
20
+ if (end === -1 && buffer.length < MAX_HEAD)
21
+ return;
22
+ done = true;
23
+ const out = end === -1 ? buffer : buffer.slice(0, end) + extra(buffer.slice(0, end)) + buffer.slice(end);
24
+ controller.enqueue(encoder.encode(out));
25
+ buffer = "";
26
+ },
27
+ flush(controller) {
28
+ if (!done && buffer)
29
+ controller.enqueue(encoder.encode(buffer + decoder.decode()));
30
+ },
31
+ }));
32
+ }
33
+ /** JSON-LD sûr dans un `<script>` : `<` échappé, la balise ne peut pas se fermer avant la fin. */
34
+ export const jsonLdScript = (data) => `<script type="application/ld+json">${JSON.stringify(data).replace(/</g, "\\u003c")}</script>`;
35
+ const escapeAttribute = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
36
+ /** `<link rel="canonical">` pour une adresse absolue. */
37
+ export const canonicalLink = (href) => `<link rel="canonical" href="${escapeAttribute(href)}">`;
package/dist/hgdc.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pages de confiance (`/about`, `/contact`, `/privacy`) que les agents cherchent sur chaque hôte : celles
3
+ * de l'entreprise, sur hgdc.ch. Pour l'option `redirects` de `withMarkdown`.
4
+ */
5
+ export declare const HGDC_TRUST_PAGES: Readonly<Record<string, string>>;
6
+ /** Nœud schema.org de l'entreprise, même `@id` que sur hgdc.ch. */
7
+ export declare const HGDC_ORGANIZATION: {
8
+ readonly "@type": "Organization";
9
+ readonly "@id": "https://hgdc.ch/#organization";
10
+ readonly name: "Hugo Gebel Développement Conseil";
11
+ readonly alternateName: "HGDC";
12
+ readonly url: "https://hgdc.ch";
13
+ readonly description: "Entreprise individuelle suisse : agence web, accompagnement IA, outils en ligne de la H Collection et logiciel Stratta, pour les entreprises en Suisse.";
14
+ readonly email: "contact@hgdc.ch";
15
+ readonly contactPoint: {
16
+ readonly "@type": "ContactPoint";
17
+ readonly contactType: "customer service";
18
+ readonly email: "contact@hgdc.ch";
19
+ readonly url: "https://hgdc.ch/contact";
20
+ readonly areaServed: "CH";
21
+ readonly availableLanguage: "fr";
22
+ };
23
+ readonly address: {
24
+ readonly "@type": "PostalAddress";
25
+ readonly streetAddress: "Chemin des Vignes 6";
26
+ readonly postalCode: "1135";
27
+ readonly addressLocality: "Denens";
28
+ readonly addressRegion: "VD";
29
+ readonly addressCountry: "CH";
30
+ };
31
+ };
32
+ /** Graphe JSON-LD d'un produit : l'entreprise et le site du produit, qu'elle publie. */
33
+ export declare function productJsonLd(product: {
34
+ name: string;
35
+ url: string;
36
+ description: string;
37
+ }): {
38
+ "@context": string;
39
+ "@graph": Record<string, unknown>[];
40
+ };
package/dist/hgdc.js ADDED
@@ -0,0 +1,55 @@
1
+ // Ce que chaque site de HGDC dit de l'entreprise aux agents : pages de confiance et données structurées.
2
+ // Mêmes valeurs que les mentions légales de hgdc.ch, qui font foi.
3
+ /**
4
+ * Pages de confiance (`/about`, `/contact`, `/privacy`) que les agents cherchent sur chaque hôte : celles
5
+ * de l'entreprise, sur hgdc.ch. Pour l'option `redirects` de `withMarkdown`.
6
+ */
7
+ export const HGDC_TRUST_PAGES = {
8
+ "/about": "https://hgdc.ch/a-propos",
9
+ "/contact": "https://hgdc.ch/contact",
10
+ "/privacy": "https://hgdc.ch/confidentialite",
11
+ };
12
+ /** Nœud schema.org de l'entreprise, même `@id` que sur hgdc.ch. */
13
+ export const HGDC_ORGANIZATION = {
14
+ "@type": "Organization",
15
+ "@id": "https://hgdc.ch/#organization",
16
+ name: "Hugo Gebel Développement Conseil",
17
+ alternateName: "HGDC",
18
+ url: "https://hgdc.ch",
19
+ description: "Entreprise individuelle suisse : agence web, accompagnement IA, outils en ligne de la H Collection et logiciel Stratta, pour les entreprises en Suisse.",
20
+ email: "contact@hgdc.ch",
21
+ contactPoint: {
22
+ "@type": "ContactPoint",
23
+ contactType: "customer service",
24
+ email: "contact@hgdc.ch",
25
+ url: "https://hgdc.ch/contact",
26
+ areaServed: "CH",
27
+ availableLanguage: "fr",
28
+ },
29
+ address: {
30
+ "@type": "PostalAddress",
31
+ streetAddress: "Chemin des Vignes 6",
32
+ postalCode: "1135",
33
+ addressLocality: "Denens",
34
+ addressRegion: "VD",
35
+ addressCountry: "CH",
36
+ },
37
+ };
38
+ /** Graphe JSON-LD d'un produit : l'entreprise et le site du produit, qu'elle publie. */
39
+ export function productJsonLd(product) {
40
+ return {
41
+ "@context": "https://schema.org",
42
+ "@graph": [
43
+ { ...HGDC_ORGANIZATION },
44
+ {
45
+ "@type": "WebSite",
46
+ "@id": `${product.url}/#website`,
47
+ url: product.url,
48
+ name: product.name,
49
+ description: product.description,
50
+ inLanguage: "fr-CH",
51
+ publisher: { "@id": HGDC_ORGANIZATION["@id"] },
52
+ },
53
+ ],
54
+ };
55
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { htmlToMarkdown, pageTitle } from "./markdown.js";
2
2
  export { prefersMarkdown, varyOnAccept } from "./negotiate.js";
3
3
  export { linksFooter, withMarkdown, type MarkdownOptions } from "./fetch.js";
4
+ export { canonicalLink, injectIntoHead, jsonLdScript } from "./head.js";
5
+ export { HGDC_ORGANIZATION, HGDC_TRUST_PAGES, productJsonLd } from "./hgdc.js";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { htmlToMarkdown, pageTitle } from "./markdown.js";
2
2
  export { prefersMarkdown, varyOnAccept } from "./negotiate.js";
3
3
  export { linksFooter, withMarkdown } from "./fetch.js";
4
+ export { canonicalLink, injectIntoHead, jsonLdScript } from "./head.js";
5
+ export { HGDC_ORGANIZATION, HGDC_TRUST_PAGES, productJsonLd } from "./hgdc.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/agents",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
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
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/agents#readme",