<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-llms-txt
  published:     2026-08-24
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Votre blog est illisible pour une IA. Voici la solution.

> Une part croissante de vos lecteurs arrive via un assistant IA — et ce qu'ils reçoivent, c'est votre HTML, emballé dans la navigation, des widgets React et des accordéons fermés. Cet article génère un miroir en Markdown pur de chaque article, un index /llms.txt et des bundles full-text par série, en dégradant la source MDX plutôt qu'en convertissant le HTML rendu. Avec la règle de repli qui rend l'export impossible à casser.

---
![Votre blog est illisible pour une IA. Voici la solution.](/img/v2/llms-txt.webp)

**TL;DR**

`llms.txt` est une convention proposée pour rendre un site lisible par les assistants IA — et elle n'a rien de prouvé : aucun assistant majeur (ChatGPT, Claude, Perplexity) ne confirme publiquement le lire. Je l'ai explorée quand même, surtout parce que ce blog est déjà écrit avec Claude Code qui fait l'essentiel du travail sur les fichiers ; maintenir un miroir `llms.txt` ne coûte donc presque rien. Ce que j'ai découvert en le construisant : le HTML publié, emballé dans la navigation, des widgets React et 107 accordéons `<Snippet>` fermés, est réellement *moins complet* que la source que j'ai écrite. La solution est un plugin exécuté au build qui produit un miroir en Markdown pur de chaque article, un index `/llms.txt` et un bundle full-text par série. La décision clé : dégrader la **source MDX**, jamais le HTML rendu — et ne jamais laisser un composant inconnu faire échouer l'export.

Je voulais explorer la piste `llms.txt`, surtout par curiosité, pas parce que j'avais la preuve que des lecteurs arrivaient via un assistant. L'idée — proposée par [Jeremy Howard d'Answer.AI](https://www.answer.ai/posts/2024-09-03-llmstxt.html) en septembre 2024 — est de publier un index d'un site en Markdown pur sur `/llms.txt`, pour qu'un assistant n'ait pas à se battre contre une application React pour trouver le contenu réel. On est loin d'une pratique établie : ni ChatGPT, ni Claude, ni Perplexity ne confirment publiquement que leur pipeline de récupération lit le fichier, et cela ne prendra peut-être jamais comme `robots.txt` ou `sitemap.xml`. Ce qui a fait basculer la balance pour moi : ce blog est déjà écrit avec Claude Code qui fait l'essentiel du travail sur les fichiers, donc garder un miroir `llms.txt` à jour ne serait pas une corvée manuelle — juste une étape de build en plus. Quand essayer coûte aussi peu, on essaie.

## Ce qui est généré {#what-gets-generated}

Un `yarn build`, trois artefacts, tous statiques :

```bash
$ yarn build

[markdown-export] Mirroring 247/248 blog posts to Markdown…
[markdown-export] Done — 247 article(s) in /llms.txt, 247/247 mirrored, 0 failed.
```

Le premier est `/llms.txt` (voyez le mien [en ligne](https://www.avonture.be/llms.txt)) — l'index du site, au format proposé par [llmstxt.org](https://llmstxt.org) : un titre, un résumé d'une ligne, puis chaque article sous forme de lien accompagné de sa description, groupé par sujet :

```bash
# Christophe Avonture

> Personal blog about Docker, WSL, Python, Quarto, PHP, Joomla, Docusaurus and even more

247 articles. Each has a plain-Markdown mirror at its own permalink plus `.md` (e.g. `https://www.avonture.be/blog/<slug>.md`).

## Series (full-text bundles)

Each link is every article in that series, concatenated in reading order — one request instead of many.

- [Bluesky Docusaurus component](https://www.avonture.be/llms/bluesky-docusaurus-component.txt) — 2 article(s)
- [Building and testing REST APIs](https://www.avonture.be/llms/building-and-testing-rest-apis.txt) — 5 article(s)
- [code quality](https://www.avonture.be/llms/code-quality.txt) — 10 article(s)
  … 22 more series …

## component

- [A component for showing code snippets in a Docusaurus blog](https://www.avonture.be/blog/docusaurus-snippets) — Learn how to build a custom React component to embed dynamic code snippets in your Docusaurus blog for a more interactive reader experience.
- [A Docusaurus component that alerts readers the article is over a year old](https://www.avonture.be/blog/docusaurus-old-notice) — Displays a warning banner when the page content is over a year old
  … 245 more articles, grouped under 40 topic headings …
```

Le deuxième est un miroir en Markdown pur de chaque article, à sa propre URL suffixée de `.md`. Prenez [l'article sur le bouton de retour en haut de page](/blog/docusaurus-go-top), récupérez [`/blog/docusaurus-go-top.md`](https://www.avonture.be/blog/docusaurus-go-top.md), et vous obtenez ceci — pas de JSX, pas d'accordéons, chaque fichier de code intégré en entier :

**/blog/docusaurus-go-top.md**

```markdown
<!--
  canonical-url: https://www.avonture.be/blog/docusaurus-go-top
  published:     2025-09-12
  generated-at:  2026-08-12T14:55:47.056Z
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Providing a go to the top button in Docusaurus posts

> Learn how to easily add a "go to the top" button, often with a fun animated icon, to your Docusaurus blog posts using a custom React component and theme override.

---
![Providing a go to the top button in Docusaurus posts](/img/v2/go_top_banner.webp)

**TL;DR**

This article is a quick tutorial on how to add a 'scroll to top' button to your Docusaurus blog. It walks you through implementing a custom `<ScrollToTopButton>` React component, including its CSS for styling. You'll learn how to override the default `BlogPostItem` theme component to automatically inject the button at the bottom of every blog post. The guide also provides tips on adding the button to other parts of your site, such as the blog archive or custom pages.

More on smartphones than on computers; it's pretty nice to have a *Back to top of page* button to avoid dislocating your thumb.

![Docux](/img/docux.webp) [Docux](https://github.com/Juniors017), a friend, recently developed this feature and I was really happy to add it, especially since it's terribly easy to use and adds a fun element; it's cool.

Just scroll down any page of this blog to see a little meerkat appear in the bottom right corner. Click on it and take the lift back to the top of the page.

## What it looks like

Here is this very page, scrolled down. The meerkat sits in the bottom right corner, floating above your own content:

> Screenshot — https\://www\.avonture.be/blog/docusaurus-go-top
>
> ![The meerkat scroll-to-top button, bottom right, on a scrolled blog post](./images/go_top_button.webp)

Up close, it's a round button holding whatever icon you hand it:

…
```

Le troisième est celui auquel je n'aurais pas pensé tout seul : **un bundle full-text par [série](/blog/docusaurus-series)**, sur `/llms/<series-slug>.txt`, qui concatène tous les articles de la série dans l'ordre de lecture. Le bundle [Docusaurus components](https://www.avonture.be/llms/creating-docusaurus-components.txt) fait 710 Ko de Markdown pur — 23 articles qu'un assistant peut récupérer en une seule requête au lieu de 23.

## Pourquoi la source, et jamais le HTML {#why-the-source-and-never-the-html}

Tous les outils du domaine reconvertissent le HTML rendu en Markdown. C'est la mauvaise direction, et la raison n'est pas esthétique :

* **Le HTML a déjà perdu de l'information.** Ce blog replie 107 snippets de code dans des accordéons `<Snippet>` fermés, certains composants n'affichent rien avant l'hydratation, et les images sont derrière du lazy loading — un passage HTML → Markdown ne peut récupérer que ce que le HTML contient, et le HTML contient moins que la source.
* **La source est déjà du Markdown.** Un fichier MDX, c'est du Markdown avec un peu de JSX saupoudré dessus. Aller de la source vers le Markdown, c'est gérer quelques dizaines de noms de composants ; aller du HTML vers le Markdown, c'est faire de l'ingénierie inverse sur tout un pipeline de rendu.
* **Le fichier généré est réellement meilleur que la page.** `<Snippet source="./files/compose.yaml">` devient un bloc de code délimité contenant le fichier entier. Le lecteur du miroir obtient le code que le lecteur de la page doit cliquer pour révéler.
* **Rien n'est inventé.** Chaque composant de ce blog emballe soit du contenu écrit par l'auteur, soit pointe vers un fichier sur le disque. Aucun composant ne fabrique du contenu à l'exécution — une dégradation au niveau de la source peut donc être complète par construction.

Le résultat n'est pas un export dégradé de mon site. C'est une version *plus complète*.

## La mise en place {#building-it}

### La table de dégradation {#the-degradation-table}

Mes articles ne sont pas du Markdown pur : c'est du MDX, du Markdown mélangé à quelques dizaines de composants React maison — `<Snippet>` pour un bloc de code repliable, `<AlertBox>` pour un encart, `<Link>` pour une référence croisée, et ainsi de suite. Chacun existe pour ajouter un peu d'interactivité à la page, et chacun est exactement ce qu'un miroir Markdown ne peut pas rendre tel quel. Le plugin doit donc savoir, composant par composant, en quel Markdown pur le transformer. Le tout est un pipeline remark : parser la source MDX en AST, la parcourir, remplacer chaque nœud JSX par des nœuds Markdown purs, puis sérialiser. Chaque composant a sa règle :

| Composant                                                     | Utilisations | Dégradation                                                     |
| ------------------------------------------------------------- | -----------: | --------------------------------------------------------------- |
| `Snippet`                                                     |          917 | bloc délimité ; `source=` (841×) résolu et intégré en entier    |
| `Link`                                                        |          779 | `[text](href)`                                                  |
| `AlertBox`                                                    |          546 | `> **{title}:** …` (le variant devient le mot-clé)              |
| `Terminal`                                                    |          455 | bloc ` ```bash ` ; `source=` (146×) résolu                      |
| `TLDR`                                                        |          253 | `> **TL;DR** …`                                                 |
| `BrowserWindow`                                               |           98 | les enfants, plus une légende `> Screenshot — {url}`            |
| `StepsCard`                                                   |           55 | liste ordonnée                                                  |
| `ProjectSetup`                                                |           28 | `### Project: {folderName}` puis chaque `Snippet` en bloc titré |
| `Details`                                                     |           18 | laissé en `<details>` — déjà du Markdown valide                 |
| `Prerequisite`                                                |           10 | liste à puces                                                   |
| `Columns` / `Column`                                          |            9 | sections successives                                            |
| `Reaction`, `ScrollToTopButton`, `Bluesky`, `RelatedPosts`, … |         \~15 | **supprimés** — de l'interface, pas du contenu                  |
| `Trees` / `Folder` / `File`                                   |            0 | **rien à faire** (voir ci-dessous)                              |

Cette dernière ligne est ma préférée dans tout le plugin. Ces composants existent parce qu'un plugin remark transforme une arborescence ASCII de la source en composants React. Ne pas appliquer ce plugin restitue l'arborescence ASCII d'origine, gratuitement. La bonne règle était de n'écrire aucune règle.

**plugins/markdown-export-plugin/degrade.cjs**

````cjs
/**
 * @fileoverview plugins/markdown-export-plugin/degrade.cjs
 *
 * Turns a raw blog-post source (the .md/.mdx file exactly as the author typed it —
 * NOT the rendered HTML) into a plain, self-contained Markdown document: no React,
 * no JSX, no collapsed accordions. Used by plugins/markdown-export-plugin/index.cjs
 * to write the `/blog/<slug>.md` mirrors and the `/llms*.txt` indexes.
 *
 * THE ONE RULE THAT MUST NEVER BE BROKEN
 * ----------------------------------------------------------------------------
 * An unrecognised component never fails the export. Its wrapper is discarded and
 * its (already-degraded) children take its place in the tree. The component's
 * name is recorded in `unknownComponents` so the caller can warn once per build —
 * that warning, not this table, is what keeps the export at 100% coverage as new
 * components get added. See COMPONENT_RULES below for the components this file
 * currently understands explicitly.
 *
 * WHY require() WORKS HERE FOR ESM-ONLY PACKAGES
 * ----------------------------------------------------------------------------
 * `unified`/`remark-*` ship ESM-only. This file stays CommonJS (matching every
 * other plugin in plugins/) because Node 20.19+ (this project's own image is
 * node:20-bookworm-slim — see Dockerfile) resolves `require("esm-package")`
 * synchronously; each import below reads `.default` (or the package's own named
 * export, for `unified` itself) exactly as that interop exposes it. Verified
 * against the actual devcontainer runtime before writing this file.
 */

const fs = require("fs");
const path = require("path");
const { unified } = require("unified");
// createProcessor with format:"mdx" — NOT format:"md" — is load-bearing, and not
// the obvious choice. @mdx-js/mdx's own core.js only attaches remark-mdx (the JSX/
// expression tokenizer) when format !== "md"; under format:"md" a `<Snippet …/>`
// parses as an inert `html` text node instead of a component node, which is *exactly*
// what Docusaurus wants for its own hast-level raw-HTML passthrough, but is useless
// here — nothing would ever reach COMPONENT_RULES. format:"mdx" gives real
// mdxJsxFlowElement/mdxJsxTextElement nodes for every file regardless of its .md/.mdx
// extension (verified against this corpus's few .md files that even have top-level
// `import` statements — they still parse fine). Confirmed empirically while writing
// this file.
const { createProcessor } = require("@mdx-js/mdx");
// Used only by the small standalone `inlineParser` below (parseInlineMarkdown) —
// the main parser gets its own parsing from createProcessor, which attaches
// remark-parse internally.
const remarkParse = require("remark-parse").default;
const remarkStringify = require("remark-stringify").default;
const remarkFrontmatter = require("remark-frontmatter").default;
const remarkGfm = require("remark-gfm").default;
const remarkDirective = require("remark-directive").default;
// Docusaurus enables MDX-1-compat raw HTML comments by default (markdown.mdx1Compat.comments),
// which is what lets `<!-- truncate -->` (and any other `<!-- … -->`) live inside MDX content
// without a syntax error — plain remark-mdx does not parse that syntax at all. Same package
// @docusaurus/mdx-loader itself uses (see its processor.js), so this mirrors the real pipeline.
const remarkComment = require("@slorber/remark-comment").default;
// Both public @docusaurus/utils exports, applied as raw-text preprocessing on the
// source BEFORE parsing — exactly mirroring @docusaurus/mdx-loader's own
// preprocessContent() (see its preprocessor.js). Skipping either would reintroduce
// a crash format:"mdx" would otherwise hit on real corpus content:
// escapeMarkdownHeadingIds turns `## Title {#id}` into an escaped `\{#id}` so
// remark-mdx's expression tokenizer never tries (and fails) to parse `#id` as JS;
// admonitionTitleToDirectiveLabel turns Docusaurus's `:::tip Free text title`
// shorthand into remark-directive's `:::tip[Free text title]` label syntax.
const {
  escapeMarkdownHeadingIds,
  admonitionTitleToDirectiveLabel,
} = require("@docusaurus/utils");
const { resolveSourcePath } = require("../remark-snippet-loader/index.cjs");

// `:::tip Title text` (Docusaurus's own admonition syntax — 7 articles use it)
// needs one more thing standard remark-directive doesn't provide on its own: an AST
// pass that reads the bracket label (produced by admonitionTitleToDirectiveLabel
// above) back off into `data.hProperties` (Docusaurus's own admonitions plugin — an
// *internal* path, not published API, so this is wrapped defensively: if it ever
// moves in a future @docusaurus/core bump, admonitions degrade through the generic
// "unknown directive" fallback below instead of crashing the build).
let admonitionsPlugin = null;
try {
  admonitionsPlugin = require("@docusaurus/mdx-loader/lib/remark/admonitions").default;
} catch {
  admonitionsPlugin = null;
}
const ADMONITION_KEYWORDS = [
  "secondary",
  "info",
  "success",
  "danger",
  "note",
  "tip",
  "warning",
  "important",
  "caution",
];

// Small, deliberately narrower sibling of remark-snippet-loader's own table:
// only used to label fenced code blocks, so an unmapped extension merely means
// "no syntax highlighting" instead of a broken export.
const EXTENSION_TO_LANG = {
  js: "javascript",
  jsx: "jsx",
  ts: "typescript",
  tsx: "typescript",
  py: "python",
  php: "php",
  sh: "bash",
  bash: "bash",
  zsh: "bash",
  ".zshrc": "bash",
  ".bashrc": "bash",
  css: "css",
  html: "html",
  json: "json",
  yml: "yaml",
  yaml: "yaml",
  md: "markdown",
  dockerfile: "docker",
  ini: "ini",
  sql: "sql",
  xml: "xml",
};

function langForFilename(filename) {
  if (!filename) return "";
  // Some `filename=` attrs carry a human annotation after the real name, e.g.
  // "InvoiceProcessor.php (staged diff)" — kept verbatim in the displayed
  // title, but path.extname() on the raw string would return ".php (staged
  // diff)" (everything after the *last* dot), which then leaks a space into
  // the fenced code block's language info-string. Strip the annotation first.
  const withoutAnnotation = filename.replace(/\s*\(.*\)\s*$/, "").trim();
  const base = path.basename(withoutAnnotation).toLowerCase();
  if (base === "dockerfile") return "docker";
  const ext = path.extname(base).slice(1);
  return EXTENSION_TO_LANG[ext] || EXTENSION_TO_LANG[base] || ext || "";
}

// ---------------------------------------------------------------------------
// Reading a `source="…"` file — mirrors remark-snippet-loader's own fallback:
// never throw, always return text (an error comment at worst).
// ---------------------------------------------------------------------------

function readSourceFile(sourcePath, ctx) {
  const absolutePath = resolveSourcePath(sourcePath, ctx.currentFileDir, ctx.projectRoot);
  try {
    return fs.readFileSync(absolutePath, "utf-8");
  } catch (err) {
    return `Error loading source file: ${sourcePath} (${err.code || err.message})`;
  }
}

// ---------------------------------------------------------------------------
// Safe literal evaluation of JSX expression attributes (`prop={…}`).
//
// MDX attaches a full acorn/acorn-jsx estree to `mdxJsxAttributeValueExpression`
// attributes. We walk it for the shapes that actually appear in this corpus —
// string/number/boolean/array/object literals, template literals with no
// interpolation, `require("./x").default` (StepsCard, ShortcutList, Image,
// DownloadButton), and small JSX fragments (ShortcutList's `desc`). Anything
// else (a variable reference, a function call we don't recognise) resolves to
// `undefined` rather than guessing — callers must treat that as "no value".
// ---------------------------------------------------------------------------

function literalFromEstree(node) {
  if (!node) return undefined;

  switch (node.type) {
    case "Literal":
      return node.value;

    case "TemplateLiteral":
      // Only static templates (no ${…}) can be read without a real JS
      // evaluator — exactly the `installOutput={`\nfoo\n`}` pattern used by
      // <Prerequisite>.
      if (node.expressions.length === 0) {
        return node.quasis.map((q) => q.value.cooked).join("");
      }
      return undefined;

    case "ArrayExpression":
      return node.elements.map((el) => (el ? literalFromEstree(el) : undefined));

    case "ObjectExpression": {
      const obj = {};
      for (const prop of node.properties) {
        if (prop.type !== "Property") continue;
        const key = prop.key.type === "Identifier" ? prop.key.name : prop.key.value;
        obj[key] = literalFromEstree(prop.value);
      }
      return obj;
    }

    case "UnaryExpression":
      if (node.operator === "-") {
        const value = literalFromEstree(node.argument);
        return typeof value === "number" ? -value : undefined;
      }
      return undefined;

    // require("./file.ext") or require("./file.ext").default — the pattern
    // used to pass a webpack-imported asset (Image, DownloadButton). We can't
    // know the final fingerprinted build URL from source alone, so we surface
    // the raw relative path and let the caller decide how to degrade it.
    case "CallExpression":
      if (node.callee?.type === "Identifier" && node.callee.name === "require") {
        const arg = node.arguments[0];
        if (arg?.type === "Literal" && typeof arg.value === "string") {
          return { __requirePath: arg.value };
        }
      }
      return undefined;

    case "MemberExpression": {
      const objectValue = literalFromEstree(node.object);
      if (
        objectValue &&
        typeof objectValue === "object" &&
        "__requirePath" in objectValue
      ) {
        // `.default` (or any property) on a require() result — the path is
        // the only part we can use, so keep it as-is.
        return objectValue;
      }
      return undefined;
    }

    case "JSXFragment":
    case "JSXElement":
      return { __jsxText: jsxToText(node) };

    default:
      return undefined;
  }
}

// Best-effort flattening of small JSX fragments embedded in an expression
// attribute (e.g. ShortcutList's `desc: <>Press <kbd>Enter</kbd></>`). This is
// NOT the mdast pipeline — it walks acorn-jsx estree nodes, a different shape.
// Deliberately returns *plain* text, with formatting tags unwrapped rather than
// translated to Markdown syntax (`` `…` ``/`**…**`): the result lands in a
// single mdast `text` node (see shortcutDescToText), and remark-stringify would
// correctly escape any literal backtick/asterisk it contains right back into
// `\`…\`` — i.e. synthesizing Markdown syntax here just to have it escaped away
// a moment later. Losing kbd/bold emphasis in this one nested-JSX-in-attribute
// shape (a single ShortcutList article, at the time of writing) is an accepted,
// minor simplification — the words themselves are never lost.
function jsxToText(node) {
  if (!node) return "";
  switch (node.type) {
    case "JSXFragment":
      return node.children.map(jsxToText).join("");
    case "JSXElement": {
      const tag = (node.openingElement?.name?.name || "").toLowerCase();
      const inner = node.children.map(jsxToText).join("");
      if (tag === "br") return "\n";
      return inner;
    }
    case "JSXText":
      return node.value;
    case "JSXExpressionContainer": {
      const value = literalFromEstree(node.expression);
      return typeof value === "string" ? value : "";
    }
    default:
      return "";
  }
}

function degradeAttrValue(attr) {
  if (attr.value === null) return true; // boolean shorthand: `typewriter`
  if (typeof attr.value === "string") return attr.value;
  if (attr.value && attr.value.type === "mdxJsxAttributeValueExpression") {
    const program = attr.value.data?.estree?.body?.[0];
    if (program?.type === "ExpressionStatement") {
      return literalFromEstree(program.expression);
    }
  }
  return undefined;
}

function getAttrs(node) {
  const attrs = {};
  for (const attr of node.attributes || []) {
    if (attr.type !== "mdxJsxAttribute") continue; // skips {...spread} attrs
    attrs[attr.name] = degradeAttrValue(attr);
  }
  return attrs;
}

// ---------------------------------------------------------------------------
// mdast → plain text (used only where a component needs flat text: Terminal's
// children, Prerequisite already gets its text from attrs). Block siblings
// (paragraphs) join on "\n"; a node's own children concatenate directly —
// good enough for the prose/terminal-output shapes this corpus actually uses.
// ---------------------------------------------------------------------------

function mdastToText(node) {
  if (node == null) return "";
  if (Array.isArray(node)) return node.map(mdastToText).join("\n");
  if (node.type === "break") return "\n";
  if (typeof node.value === "string") return node.value;
  if (node.children) return node.children.map(mdastToText).join("");
  return "";
}

// ---------------------------------------------------------------------------
// Reader-adjustable values — `default`, written
// directly inside `<Terminal>`/`<Snippet>` text by the author (`%%`, not
// `{{`, because a bare `{` inside literal MDX children opens a JS expression
// — see src/components/Vars/substitute.js for the full rationale; this is
// that same live-component contract, resolved at export time instead of
// render time). `=`, not `:`, separates the name from its default: this
// file's own `remarkDirective` (below, for `:::tip` admonitions) treats any
// bare `word:word` text as a false-positive inline directive — confirmed
// empirically, a colon-separated marker logged a spurious "unknown
// directive" warning for every occurrence. The default lives in the marker
// itself, so resolving it here needs no lookup against a `<Vars>` node
// elsewhere in the tree: a plain regex swap. Without this, the exported
// `.md`/`llms.txt` would show the literal marker — invalid input for a
// reader or an LLM to copy-paste.
// ---------------------------------------------------------------------------

const VAR_MARKER_RE = /%%(\w+)=([^%]*)%%/g;

function resolveVarMarkers(text) {
  if (typeof text !== "string") return text;
  return text.replace(VAR_MARKER_RE, (_match, _name, defaultValue) => defaultValue);
}

// ---------------------------------------------------------------------------
// String attributes that carry their own inline Markdown, not literal text —
// AlertBox/Details `title`/`label` routinely embed `` `code` `` (over 30 uses
// across this corpus), and StepsCard's `steps[].content` is markdown by
// contract (the live component runs it through its own parseMarkdown()).
// Wrapping such a string in a single text node, as everything else in this
// file does for genuinely literal text, makes remark-stringify escape the
// author's `` ` ``/`*` right back into `\`…\`` — this parses the string as its
// own tiny Markdown document and lifts out the real inline nodes instead.
// ---------------------------------------------------------------------------

const inlineParser = unified().use(remarkParse).use(remarkGfm);

function parseInlineMarkdown(text) {
  if (typeof text !== "string" || text === "") return [textNode("")];
  const tree = inlineParser.parse(text);
  const paragraphs = (tree.children || []).filter((node) => node.type === "paragraph");
  if (paragraphs.length === 0) return [textNode(text)];
  return paragraphs.flatMap((p) => p.children);
}

// ---------------------------------------------------------------------------
// Custom-component degradation table.
// Each handler receives the JSX node (children already recursively degraded
// into plain mdast) and the shared ctx, and returns an array of mdast nodes.
// ---------------------------------------------------------------------------

const ALERT_LABELS = {
  info: "Information",
  note: "Note",
  tip: "Tip",
  caution: "Caution",
  important: "Important",
  highlyImportant: "Highly Important",
  coreConcept: "Core Concept",
  danger: "Danger",
};

function textNode(value) {
  return { type: "text", value };
}
function paragraph(children) {
  return { type: "paragraph", children };
}
function strongText(value) {
  return { type: "strong", children: [textNode(value)] };
}
// Same as strongText, but for an author-written string that may itself
// contain Markdown syntax (AlertBox/Details title & label, StepsCard title) —
// see parseInlineMarkdown's doc comment.
function strongParsed(value) {
  return { type: "strong", children: parseInlineMarkdown(value) };
}
function codeBlock(value, lang) {
  return { type: "code", lang: lang || null, value: value.replace(/\n$/, "") };
}

const COMPONENT_RULES = {
  // --- Content-bearing leaves --------------------------------------------
  Link(node) {
    const attrs = getAttrs(node);
    const url = typeof attrs.to === "string" ? attrs.to : attrs.href || "";
    return [{ type: "link", url, children: node.children || [] }];
  },

  ConnectionInfo(node) {
    const attrs = getAttrs(node);
    const items = Array.isArray(attrs.items) ? attrs.items : [];
    if (items.length === 0) return [];
    const out = [];
    if (typeof attrs.title === "string") out.push(paragraph([strongParsed(attrs.title)]));
    out.push({
      type: "table",
      align: [null, null],
      children: [
        tableRow(["Label", "Value"]),
        ...items.map((item) =>
          tableRow([
            item?.label || "",
            [{ type: "inlineCode", value: item?.value || "" }],
          ]),
        ),
      ],
    });
    return out;
  },

  // Props-only (no children) — a small "Quick Jump: A | B" line, links resolved
  // against the same in-page anchors the headings below stringify to, so they
  // stay meaningful in the flat mirror.
  QuickJump(node) {
    const attrs = getAttrs(node);
    const links = Array.isArray(attrs.links) ? attrs.links : [];
    if (links.length === 0) return [];
    const title = typeof attrs.title === "string" ? attrs.title : "Quick Jump";
    const linkNodes = links.flatMap((link, index) => {
      const item = {
        type: "link",
        url: typeof link?.to === "string" ? link.to : "",
        children: [textNode(typeof link?.label === "string" ? link.label : "")],
      };
      return index === 0 ? [item] : [textNode(" | "), item];
    });
    return [paragraph([strongText(`${title}:`), textNode(" "), ...linkNodes])];
  },

  Snippet(node, ctx) {
    const attrs = getAttrs(node);
    let code;
    let lang;
    if (typeof attrs.source === "string") {
      code = readSourceFile(attrs.source, ctx);
      lang = langForFilename(attrs.filename || attrs.source);
    } else if (typeof attrs.code === "string") {
      code = attrs.code;
      lang = langForFilename(attrs.filename) || attrs.lang || "";
    } else {
      code = mdastToText(node.children);
      lang = langForFilename(attrs.filename) || "";
    }
    const title =
      typeof attrs.filename === "string" ? [paragraph([strongText(attrs.filename)])] : [];
    return [...title, codeBlock(resolveVarMarkers(code), lang)];
  },

  Terminal(node, ctx) {
    const attrs = getAttrs(node);
    const code =
      typeof attrs.source === "string"
        ? readSourceFile(attrs.source, ctx)
        : mdastToText(node.children);
    return [codeBlock(resolveVarMarkers(code), "bash")];
  },

  // Reader-values bar — UI only, nothing for the plain-Markdown
  // mirror to show. The values it declares are already the same defaults
  // baked into every `default` marker (resolveVarMarkers above), so
  // dropping this node loses no information.
  Vars() {
    return [];
  },

  // Inline prose sibling of the marker (see the VAR_MARKER_RE comment above):
  // `<Var name="…">default</Var>` inside a paragraph. children is already
  // the literal default text — keep it, drop the wrapper.
  Var(node) {
    return node.children || [];
  },

  // `<Code>text<Var name="…">default</Var>text</Code>` (see Code.js) — one
  // code span mixing literal text and a `<Var>`. The tree walk is bottom-up
  // (see transformChildren below), so `node.children` here already has each
  // nested `<Var>` resolved to its plain default text; flatten it into one
  // `inlineCode`, same as a literal `<code>`/`<kbd>` HTML tag two rules up.
  Code(node) {
    return [{ type: "inlineCode", value: mdastToText(node.children) }];
  },

  // --- Wrappers whose children already carry the content ------------------
  TLDR(node) {
    return [paragraph([strongText("TL;DR")]), ...(node.children || [])];
  },

  AlertBox(node) {
    const attrs = getAttrs(node);
    // attrs.title is author-written and routinely embeds `` `code` `` (30+
    // uses in this corpus) — parse it, then append the literal ":" as its own
    // trailing text node rather than folding it into a string first.
    const labelNodes =
      typeof attrs.title === "string"
        ? [...parseInlineMarkdown(attrs.title), textNode(":")]
        : [textNode(`${ALERT_LABELS[attrs.variant] || ALERT_LABELS.info}:`)];
    return [
      {
        type: "blockquote",
        children: [
          paragraph([{ type: "strong", children: labelNodes }]),
          ...(node.children || []),
        ],
      },
    ];
  },

  Details(node) {
    const attrs = getAttrs(node);
    const summary = typeof attrs.label === "string" ? attrs.label : "Details";
    return [paragraph([strongParsed(summary)]), ...(node.children || [])];
  },

  Prerequisite(node) {
    const attrs = getAttrs(node);
    const lines = [paragraph([textNode(`Prerequisite: ${attrs.name || ""}`)])];
    if (attrs.install) {
      const installText = `$ ${attrs.install}${attrs.installOutput ? `\n${attrs.installOutput}` : ""}`;
      lines.push(codeBlock(installText, "bash"));
    }
    if (attrs.check) {
      const checkText = `$ ${attrs.check}${attrs.checkOutput ? `\n${attrs.checkOutput}` : ""}`;
      lines.push(paragraph([textNode("Verify:")]));
      lines.push(codeBlock(checkText, "bash"));
    }
    return lines;
  },

  BrowserWindow(node) {
    const attrs = getAttrs(node);
    const caption =
      typeof attrs.url === "string"
        ? `Screenshot — ${resolveVarMarkers(attrs.url)}`
        : "Screenshot";
    return [
      {
        type: "blockquote",
        children: [paragraph([textNode(caption)]), ...(node.children || [])],
      },
    ];
  },

  StepsCard(node) {
    const attrs = getAttrs(node);
    const out = [];
    if (typeof attrs.title === "string") out.push(paragraph([strongParsed(attrs.title)]));
    const steps = Array.isArray(attrs.steps) ? attrs.steps : [];
    if (steps.length > 0) {
      out.push({
        type: "list",
        ordered: attrs.variant !== "remember",
        children: steps.map((step) => stepToListItem(step)),
      });
    }
    return out;
  },

  ShortcutList(node) {
    const attrs = getAttrs(node);
    const items = Array.isArray(attrs.items) ? attrs.items : [];
    if (items.length === 0) return [];
    return [
      {
        type: "table",
        align: [null, null],
        children: [
          tableRow(["Shortcut", "Description"]),
          ...items.map((item) =>
            tableRow([keysToInlineNodes(item.keys), shortcutDescToText(item.desc)]),
          ),
        ],
      },
    ];
  },

  ProjectSetup(node) {
    const attrs = getAttrs(node);
    const out = [paragraph([strongText(`Project: ${attrs.folderName || "my-project"}`)])];
    for (const child of node.children || []) {
      out.push(child);
    }
    return out;
  },
  // <Guideline>/<EmptyFolder> render null in React (ProjectSetup reads them as
  // configuration, not layout) — degrade to the same instructional text a
  // reader of the live page would see.
  Guideline(node) {
    // node.children are already-degraded block nodes (typically one or more
    // paragraphs) — they must be siblings inside the blockquote, never spliced
    // into the "Note:" paragraph's own inline children (a block node nested
    // inside a paragraph is invalid mdast; remark-stringify does not reject it,
    // it just emits garbled HTML-entity-escaped text — verified on this corpus).
    return [
      {
        type: "blockquote",
        children: [paragraph([strongText("Note:")]), ...(node.children || [])],
      },
    ];
  },
  EmptyFolder(node) {
    const attrs = getAttrs(node);
    return [paragraph([textNode(`(create empty directory: ${attrs.name || "?"})`)])];
  },

  // --- Cards ----------------------------------------------------------------
  Card(node) {
    return node.children || [];
  },
  CardHeader(node) {
    return [{ type: "heading", depth: 4, children: inlineOnly(node.children) }];
  },
  CardBody(node) {
    return node.children || [];
  },
  CardFooter(node) {
    return node.children || [];
  },
  CardImage(node) {
    const attrs = getAttrs(node);
    const src = resolveDisplayableImageSrc(attrs.src);
    return src ? [paragraph([{ type: "image", url: src, alt: attrs.alt || "" }])] : [];
  },

  // --- Simple layout / leaf components --------------------------------------
  Columns(node) {
    return node.children || [];
  },
  Column(node) {
    return node.children || [];
  },
  Highlight(node) {
    return [{ type: "strong", children: node.children || [] }];
  },
  Image(node) {
    const attrs = getAttrs(node);
    const src = resolveDisplayableImageSrc(attrs.src);
    return src
      ? [paragraph([{ type: "image", url: src, alt: attrs.alt || attrs.title || "" }])]
      : [paragraph([textNode(`(image: ${attrs.alt || attrs.title || "untitled"})`)])];
  },
  DownloadButton(node) {
    const attrs = getAttrs(node);
    const requirePath =
      attrs.file && typeof attrs.file === "object" ? attrs.file.__requirePath : null;
    const label = attrs.label || "Download";
    // The final build asset URL is fingerprinted by webpack and unknowable
    // from source alone (see literalFromEstree's CallExpression case) — link
    // text names the file instead of guessing a URL that could 404.
    return [
      paragraph([
        textNode(`${label}: `),
        { type: "inlineCode", value: requirePath || "?" },
      ]),
    ];
  },

  // --- Pure interface widgets: no textual content, drop entirely -----------
  Reaction: () => [],
  ScrollToTopButton: () => [],
  KonamiEasterEgg: () => [],
  RelatedPosts: () => [],
  SeriesPosts: () => [],
  PostCard: () => [],
  Bluesky: () => [],
  AIIcon: () => [],
  TriedIt: () => [],
  TypoReport: () => [],

  // Trees/Folder/File need NO rule at all: they only exist after
  // remark-tree-to-component rewrites a fenced ```tree block, and this file
  // degrades the raw source *before* that plugin ever runs — so the fenced
  // block is still a plain `code` node here, already valid Markdown.
};

function inlineOnly(nodes) {
  // Best-effort: unwrap paragraphs into their inline children, for contexts
  // (headings) that cannot contain block content.
  const out = [];
  for (const node of nodes || []) {
    if (node.type === "paragraph") out.push(...node.children);
    else out.push(node);
  }
  return out;
}

function resolveDisplayableImageSrc(src) {
  // Absolute paths (`/img/…`) and full URLs resolve identically whether the
  // reader is on the HTML page or on the flat `/blog/<slug>.md` mirror, so
  // they survive as real image links. A relative `require("./x.webp")` result
  // is fingerprinted by webpack into a hashed build URL this script has no
  // way to predict — see [[0082]]'s "Ce qui est garanti" — so it is dropped
  // rather than emitting a link that would 404.
  if (typeof src === "string" && (src.startsWith("/") || /^https?:\/\//.test(src))) {
    return src;
  }
  return null;
}

// StepsCard's `steps[].content`/`steps[].substeps[]` strings are Markdown by
// contract — the live component runs each through its own parseMarkdown() —
// hence parseInlineMarkdown() rather than a literal textNode() here.
function stepToListItem(step) {
  if (typeof step === "string") {
    return { type: "listItem", children: [paragraph(parseInlineMarkdown(step))] };
  }
  const content = typeof step?.content === "string" ? step.content : "";
  const children = [paragraph(parseInlineMarkdown(content))];
  if (Array.isArray(step?.substeps) && step.substeps.length > 0) {
    children.push({
      type: "list",
      ordered: false,
      children: step.substeps.map((s) => ({
        type: "listItem",
        children: [paragraph(parseInlineMarkdown(typeof s === "string" ? s : ""))],
      })),
    });
  }
  return { type: "listItem", children };
}

function shortcutDescToText(desc) {
  if (typeof desc === "string") return desc;
  if (desc && typeof desc === "object" && "__jsxText" in desc) return desc.__jsxText;
  return "";
}

// `cells`: each entry is either a plain string (wrapped in a single text node)
// or a pre-built array of inline mdast nodes (e.g. an `inlineCode` node) — the
// latter for any cell that needs real Markdown formatting rather than escaped
// literal characters. mdast-gfm needs no header flag on the row itself: by
// convention the *first* row of a `table` node's children is the header row.
function tableRow(cells) {
  return {
    type: "tableRow",
    children: cells.map((cell) => ({
      type: "tableCell",
      children: Array.isArray(cell) ? cell : [textNode(cell)],
    })),
  };
}

function keysToInlineNodes(keys) {
  if (!Array.isArray(keys) || keys.length === 0) return [textNode("")];
  const nodes = [];
  keys.forEach((key, index) => {
    if (index > 0) nodes.push(textNode("+"));
    nodes.push({ type: "inlineCode", value: key });
  });
  return nodes;
}

// ---------------------------------------------------------------------------
// Plain HTML tags authored directly in MDX (Table B) — `<div className="…">`,
// `<h4>`, `<strong>`, `<em>`, `<code>`, `<a href>`… These are NOT custom
// components: remark-mdx parses every JSX tag, lowercase or not, into the same
// mdxJsxFlowElement/mdxJsxTextElement node types, so without this table they
// would fall through to the generic "unknown → unwrap" rule and silently lose
// their formatting (bold/italic/inline-code/heading level).
// ---------------------------------------------------------------------------

const HTML_HEADING_DEPTH = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6 };

function degradeHtmlTag(node) {
  const name = node.name.toLowerCase();
  const children = node.children || [];

  if (HTML_HEADING_DEPTH[name]) {
    return [
      {
        type: "heading",
        depth: HTML_HEADING_DEPTH[name],
        children: inlineOnly(children),
      },
    ];
  }
  if (name === "p") return [paragraph(children)];
  if (name === "blockquote") return [{ type: "blockquote", children }];
  if (name === "strong" || name === "b") return [{ type: "strong", children }];
  if (name === "em" || name === "i") return [{ type: "emphasis", children }];
  if (name === "del" || name === "s") return [{ type: "delete", children }];
  if (name === "code" || name === "kbd")
    return [{ type: "inlineCode", value: mdastToText(children) }];
  if (name === "a") {
    const attrs = getAttrs(node);
    return [{ type: "link", url: attrs.href || "", children }];
  }
  if (name === "img") {
    const attrs = getAttrs(node);
    const src = resolveDisplayableImageSrc(attrs.src);
    if (src) return [{ type: "image", url: src, alt: attrs.alt || "" }];
    // Relative/require()'d src can't be resolved to a real URL (see
    // resolveDisplayableImageSrc) — keep the alt text rather than vanish
    // entirely, since a bare <img> has no children to fall back to.
    return attrs.alt ? [textNode(attrs.alt)] : [];
  }
  if (name === "br") return [{ type: "break" }];
  if (name === "hr") return [{ type: "thematicBreak" }];

  // div, span, section, article, and any other purely structural tag: unwrap.
  return children;
}

// ---------------------------------------------------------------------------
// Tree walk: bottom-up so a nested custom component is already resolved
// before its parent inspects `node.children`.
// ---------------------------------------------------------------------------

function transformChildren(children, ctx) {
  const out = [];
  for (const child of children || []) {
    out.push(...transformNode(child, ctx));
  }
  return out;
}

function transformNode(node, ctx) {
  if (node.children) {
    node.children = transformChildren(node.children, ctx);
  }

  if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
    return degradeComponent(node, ctx);
  }
  if (node.type === "mdxFlowExpression" || node.type === "mdxTextExpression") {
    // A bare {expression} in prose, outside any attribute. None exist in this
    // corpus outside of code fences (verified) — best-effort fallback rather
    // than a crash if one ever appears.
    return typeof node.value === "string" && node.value.trim()
      ? [textNode(node.value)]
      : [];
  }
  if (node.type === "mdxjsEsm") {
    return []; // import/export statements — not content.
  }
  if (node.type === "yaml") {
    return []; // frontmatter — the caller writes its own header instead.
  }
  if (node.type === "comment") {
    return []; // `<!-- truncate -->` and any other raw HTML comment — not content.
  }
  if (node.type === "containerDirective" || node.type === "leafDirective") {
    return degradeDirective(node);
  }
  if (node.type === "textDirective") {
    // remark-directive's inline `:name` syntax has no word-boundary check, so
    // ordinary prose like "user_id:group_id", "github.com:company/x.git" or
    // "nginx:latest" tokenizes as a textDirective too — and every occurrence
    // ever found in this corpus has been exactly that, never a real directive
    // (this blog's own `:::tip`-style directives go through containerDirective/
    // leafDirective above, not here). Not worth a per-build warning: reconstruct
    // the exact original characters from the source, silently. Unlike
    // containerDirective, a directive with no `[label]` has *no children at
    // all* to fall back to, so keeping `node.children` here would silently
    // delete the word after the colon instead.
    if (node.position && typeof ctx.rawSource === "string") {
      return [
        textNode(
          ctx.rawSource.slice(node.position.start.offset, node.position.end.offset),
        ),
      ];
    }
    return node.children || [];
  }

  return [node];
}

function degradeDirective(node) {
  // Docusaurus's admonitions plugin (when available — see the try/catch at the
  // top of this file) annotates the directive with the resolved type/title
  // instead of changing its node type, so `node.type` is still "containerDirective"
  // here even for a fully recognised `:::tip[…]`.
  const type = node.data?.hProperties?.type || node.name;
  const title = node.data?.hProperties?.title;
  const label =
    title ||
    ALERT_LABELS[type] ||
    (type ? type[0].toUpperCase() + type.slice(1) : "Note");
  return [
    {
      type: "blockquote",
      children: [paragraph([strongText(`${label}:`)]), ...(node.children || [])],
    },
  ];
}

function degradeComponent(node, ctx) {
  const name = node.name;

  if (!name) {
    // React fragment <>…</>
    return node.children || [];
  }

  const isHtmlTag = name[0] === name[0].toLowerCase();
  if (isHtmlTag) {
    return degradeHtmlTag(node);
  }

  const handler = COMPONENT_RULES[name];
  if (handler) {
    return handler(node, ctx) || [];
  }

  // The one rule that can never be skipped: keep the content, drop the
  // wrapper, and tell the caller so it can warn about the gap.
  ctx.unknownComponents.add(name);
  return node.children || [];
}

// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------

// Parse-only processor, always format:"mdx" regardless of the file's own .md/.mdx
// extension — see the createProcessor import comment above for why. Never call
// `.run()`/`.compile()`/`.process()` on this one: createProcessor bakes in the
// full remark→rehype→recma→JS-estree compile chain, and `.parse()` is the only
// method that stops cleanly at the mdast stage this file actually wants.
function buildParser() {
  return createProcessor({ format: "mdx" })
    .use(remarkFrontmatter)
    .use(remarkGfm)
    .use(remarkComment)
    .use(remarkDirective);
}

// Separate, bare processor: `.run()`-only, used solely to apply Docusaurus's
// own admonitions AST transform (`:::tip[…]` → data.hProperties) to the tree
// `buildParser` produced. Kept off the parser above because a bare unified()
// has no recma/JS-compile stage to accidentally trigger.
const directiveTransformer = admonitionsPlugin
  ? unified().use(admonitionsPlugin, true)
  : null;

const stringifier = unified().use(remarkStringify).use(remarkGfm);

/**
 * @param {string} rawSource - the full .md/.mdx file content, frontmatter included.
 * @param {object} opts
 * @param {string} opts.currentFileDir - absolute directory of the article (for
 *   resolving relative `source="./…"` attributes).
 * @param {string} [opts.projectRoot] - defaults to process.cwd().
 * @returns {Promise<{ markdown: string, unknownComponents: string[] }>}
 */
async function mdxToMarkdown(rawSource, { currentFileDir, projectRoot = process.cwd() }) {
  let preprocessed = escapeMarkdownHeadingIds(rawSource);
  if (admonitionsPlugin) {
    preprocessed = admonitionTitleToDirectiveLabel(preprocessed, ADMONITION_KEYWORDS);
  }

  const tree = buildParser().parse(preprocessed);

  if (directiveTransformer) {
    await directiveTransformer.run(tree);
  }

  const ctx = {
    currentFileDir,
    projectRoot,
    rawSource: preprocessed,
    unknownComponents: new Set(),
  };
  tree.children = transformChildren(tree.children, ctx);

  const markdown = stringifier.stringify(tree);

  return { markdown, unknownComponents: [...ctx.unknownComponents].sort() };
}

module.exports = { mdxToMarkdown };
````

### L'orchestrateur {#the-orchestrator}

Le plugin s'exécute dans `postBuild`, dégrade chaque article publié, écrit les miroirs, puis construit les deux index à partir de ce qui a réussi :

**plugins/markdown-export-plugin/index.cjs**

```cjs
/**
 * @fileoverview Docusaurus Plugin: markdown-export-plugin
 *
 * Purpose:
 * Writes a plain-Markdown mirror of every published blog post next to its HTML
 * page (`/blog/<slug>` → `/blog/<slug>.md`), plus a site-wide `/llms.txt` index
 * and a per-series `/llms/<series-slug>.txt` bundle — so a reader (human or an
 * LLM) can fetch the article's full content without React, JSX, or the 107
 * `defaultOpen={false}` accordions this corpus collapses in the HTML.
 *
 * The actual MDX → Markdown degradation lives in ./degrade.cjs; this file only
 * orchestrates: find the posts that really shipped, degrade each one, write
 * the mirrors, then build the two kinds of index on top of what didn't fail.
 *
 * Usage:
 * - Add to docusaurus.config.js `plugins` array: `"./plugins/markdown-export-plugin/index.cjs"`
 * - Runs only in `postBuild` (i.e. only during `yarn build`, never `yarn start`),
 *   same as every other postBuild plugin in this repo.
 */

const fs = require("fs");
const path = require("path");
const frontMatter = require("front-matter");
const { mdxToMarkdown } = require("./degrade.cjs");

const BLOG_DIR = "blog";

/** Recursively collects every article file under a directory (mirrors the same
 * pattern used by scripts/internal-link-opportunities.mjs and blog-feed-plugin). */
function findPosts(dir) {
  const found = [];
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const target = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      found.push(...findPosts(target));
    } else if (entry.name === "index.md" || entry.name === "index.mdx") {
      found.push(target);
    }
  }
  return found;
}

// Mirrors src/components/Blog/utils/slug.ts's createSlug() exactly. Duplicated
// rather than imported: that file is ESM written for Webpack/browser bundling
// (`export function`), which a plain Node `require()` here cannot load without
// a bundler. Keep both in sync if the slugify algorithm ever changes.
function createSlug(text) {
  return text
    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
    .toLowerCase()
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "") // strips diacritics
    .replace(/[^a-z0-9\s-]/g, "")
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-")
    .replace(/^-+|-+$/g, "");
}

// Mirrors src/components/Blog/utils/posts.ts's permalink derivation exactly —
// same "why duplicated" rationale as createSlug above.
function permalinkFor(attributes, dir) {
  if (attributes.slug) {
    return attributes.slug.startsWith("/")
      ? attributes.slug
      : `/blog/${attributes.slug.replace(/^\//, "")}`;
  }
  return `/blog/${dir}/`;
}

/**
 * @param {string} siteDir
 * @param {string} [translationDir] when building a non-default locale, the matching
 *   `i18n/<locale>/docusaurus-plugin-content-blog` tree. Any article that has a file there is
 *   read FROM there, so the mirror and the llms.txt index carry the translated title,
 *   description and body. Without it, `/fr/blog/<slug>.md` served the English Markdown under a
 *   French URL and `/fr/llms.txt` advertised English titles — the content equivalent of the
 *   duplicate-content problem plugins/i18n-seo-guard solves for HTML. See TODO 0119.
 */
function loadPosts(siteDir, translationDir = null) {
  const blogDir = path.join(siteDir, BLOG_DIR);
  const posts = [];

  for (const file of findPosts(blogDir)) {
    let sourceFile = file;

    if (translationDir) {
      const relative = path.relative(blogDir, file);
      const translated = path.join(translationDir, relative);
      if (fs.existsSync(translated)) sourceFile = translated;
    }

    const raw = fs.readFileSync(sourceFile, "utf-8");
    const { attributes } = frontMatter(raw);

    // A Markdown file under blog/ without a title isn't an article (a
    // fragment, a note) — same rule posts.js applies.
    if (!attributes.title) continue;

    const dir = path.relative(blogDir, path.dirname(file)).split(path.sep).join("/");

    posts.push({
      file,
      raw,
      // Deliberately the ENGLISH article's folder, even when `raw` came from the translation:
      // `files/` and `images/` are co-located with the source, never duplicated under i18n/.
      currentFileDir: path.dirname(file),
      permalink: permalinkFor(attributes, dir),
      title: attributes.title,
      description: attributes.description || "",
      date: attributes.date,
      mainTag: attributes.mainTag || null,
      series: attributes.series || null,
      draft: attributes.draft || false,
      unlisted: attributes.unlisted || false,
    });
  }

  return posts;
}

/**
 * Absolute URL for a site-relative path, honouring the locale's baseUrl.
 *
 * `siteConfig.url` is the bare origin and `siteConfig.baseUrl` carries the locale segment
 * (`/` for the default locale, `/fr/` for French). Concatenating `url + permalink` skips the
 * locale entirely, so the French llms.txt advertised English URLs for translated articles.
 * See TODO 0119.
 */
function absoluteUrl(siteConfig, sitePath) {
  const base = siteConfig.baseUrl.endsWith("/")
    ? siteConfig.baseUrl
    : `${siteConfig.baseUrl}/`;
  return `${siteConfig.url}${base}${String(sitePath).replace(/^\//, "")}`;
}

// YAML frontmatter dates (`date: 2024-02-23`) are auto-typed as JS Date
// objects by the YAML parser front-matter uses — string-concatenating one
// directly calls its verbose default toString(). Normalize to plain
// YYYY-MM-DD instead.
function formatDate(date) {
  if (!date) return "—";
  return date instanceof Date ? date.toISOString().slice(0, 10) : String(date);
}

// The comment carries the same info a human reader would otherwise lose once
// this file is copied or pasted elsewhere: where the rendered original lives
// (images, interactive components, the lot). Kept as an HTML comment — invisible
// in Markdown, so a reader pasting the file into a renderer isn't shown a stray
// metadata block, while it still reads as plain text in a raw view or in
// whatever an LLM is handed.
//
// Deliberately no build timestamp. One wall-clock value rewrote all 247 mirrors
// plus the 25 llms bundles on every single build (5.2 MB), which the deploy then
// had to re-upload even when no article had changed. The prose below already
// says the file is generated at build time, and `published` carries the date a
// reader actually needs.
function buildMetadataComment(post, siteConfig) {
  const url = absoluteUrl(siteConfig, post.permalink);
  return [
    "<!--",
    `  canonical-url: ${url}`,
    `  published:     ${formatDate(post.date)}`,
    "  This is a static plain-Markdown mirror generated at build time.",
    "  Visit the canonical URL above for the fully rendered page, with images and interactive components.",
    "-->",
    "",
  ].join("\n");
}

function buildHeader(post, siteConfig) {
  const lines = [buildMetadataComment(post, siteConfig), `# ${post.title}`, ""];
  if (post.description) {
    lines.push(`> ${post.description}`, "");
  }
  lines.push("---", "");
  return lines.join("\n");
}

function writeLlmsTxt(outDir, siteConfig, posts, seriesFiles) {
  const byTag = new Map();
  for (const post of posts) {
    const tag = post.mainTag || "uncategorized";
    if (!byTag.has(tag)) byTag.set(tag, []);
    byTag.get(tag).push(post);
  }

  const lines = [`# ${siteConfig.title}`, ""];
  if (siteConfig.tagline) lines.push(`> ${siteConfig.tagline}`, "");
  lines.push(
    `${posts.length} articles. Each has a plain-Markdown mirror at its own permalink ` +
      `plus \`.md\` (e.g. \`${absoluteUrl(siteConfig, "/blog/<slug>.md")}\`).`,
    "",
  );

  // This is the only place the per-series bundles are ever linked from — without
  // it they exist on disk but are unreachable from anything an LLM would fetch.
  if (seriesFiles.length > 0) {
    lines.push(
      "## Series (full-text bundles)",
      "",
      "Each link is every article in that series, concatenated in reading order — " +
        "one request instead of many.",
      "",
    );
    for (const s of seriesFiles) {
      lines.push(
        `- [${s.name}](${absoluteUrl(siteConfig, `/llms/${s.slug}.txt`)}) — ${s.count} article(s)`,
      );
    }
    lines.push("");
  }

  for (const tag of [...byTag.keys()].sort()) {
    lines.push(`## ${tag}`, "");
    const sorted = [...byTag.get(tag)].sort((a, b) => a.title.localeCompare(b.title));
    for (const post of sorted) {
      const desc = post.description ? ` — ${post.description}` : "";
      lines.push(`- [${post.title}](${absoluteUrl(siteConfig, post.permalink)})${desc}`);
    }
    lines.push("");
  }

  fs.writeFileSync(path.join(outDir, "llms.txt"), lines.join("\n"), "utf-8");
}

// Returns the series that were actually written, sorted by name — this list is
// what lets writeLlmsTxt() link to them; without it they'd be orphaned on disk.
function writeSeriesFull(outDir, posts) {
  const bySeries = new Map();
  for (const post of posts) {
    if (!post.series) continue;
    if (!bySeries.has(post.series)) bySeries.set(post.series, []);
    bySeries.get(post.series).push(post);
  }
  if (bySeries.size === 0) return [];

  const seriesDir = path.join(outDir, "llms");
  fs.mkdirSync(seriesDir, { recursive: true });

  const written = [];
  for (const [seriesName, seriesPosts] of bySeries) {
    const sorted = [...seriesPosts].sort((a, b) => new Date(a.date) - new Date(b.date));
    const chunks = [
      `# ${seriesName}`,
      "",
      `A series of ${sorted.length} article(s), in reading order.`,
      "",
    ];
    for (const post of sorted) {
      chunks.push(post.markdown, "", "---", "");
    }
    const slug = createSlug(seriesName);
    fs.writeFileSync(path.join(seriesDir, `${slug}.txt`), chunks.join("\n"), "utf-8");
    written.push({ name: seriesName, slug, count: sorted.length });
  }
  return written.sort((a, b) => a.name.localeCompare(b.name));
}

module.exports = function markdownExportPlugin() {
  return {
    name: "markdown-export-plugin",

    async postBuild({ outDir, siteDir, siteConfig, routesPaths, i18n }) {
      const currentLocale = i18n?.currentLocale;
      const defaultLocale = i18n?.defaultLocale ?? siteConfig.i18n?.defaultLocale;
      const isTranslatedLocale = Boolean(
        currentLocale && currentLocale !== defaultLocale,
      );

      const posts = loadPosts(
        siteDir,
        isTranslatedLocale
          ? path.join(siteDir, "i18n", currentLocale, "docusaurus-plugin-content-blog")
          : null,
      );

      // In a non-default locale, mirror and index ONLY the articles really translated into it.
      // `loadPosts()` reads `blog/`, i.e. the English corpus, and Docusaurus's i18n fallback
      // gives every one of them a live `/fr/` route — so without this, `/fr/llms.txt` would
      // advertise 257 English articles as the French corpus, and `/fr/blog/<slug>.md` would
      // mirror English prose under a French URL. See TODO 0119.
      let localeFiltered = posts;

      if (isTranslatedLocale) {
        const {
          collectTranslations,
        } = require("../translations-manifest-plugin/index.cjs");
        const translated = new Set(collectTranslations(siteDir)[currentLocale] ?? []);
        // These post objects carry a `permalink`, not a `slug` — assuming the latter silently
        // filtered everything out (`translated.has(undefined)` is always false).
        localeFiltered = posts.filter((post) => {
          const match = String(post.permalink ?? "").match(/blog\/([^/]+)\/?$/);
          return match ? translated.has(match[1]) : false;
        });
        console.log(
          `[markdown-export] locale ${currentLocale}: ${localeFiltered.length} translated article(s).`,
        );
      }

      // Only mirror posts that actually got a live route — this is what makes
      // "draft" (excluded in prod by Docusaurus's own blog plugin) and
      // ".unpublished/" (outside blog/, never globbed here anyway) fall out
      // for free, without re-deriving Docusaurus's own draft rules.
      // `routesPaths` carries the locale's baseUrl (`/fr/blog/<slug>/`) while `permalinkFor()`
      // builds a bare `/blog/<slug>` — comparing them directly matched nothing under a
      // non-default locale, so every mirror silently disappeared while the build stayed green.
      // This is the mirror image of the bug in blog-feed-plugin's getArticleHtml(), where the
      // prefix was present twice instead of missing once. See TODO 0119.
      const baseUrl = siteConfig.baseUrl.endsWith("/")
        ? siteConfig.baseUrl
        : `${siteConfig.baseUrl}/`;
      const withBaseUrl = (permalink) =>
        `${baseUrl}${permalink.replace(/^\//, "")}`.replace(/\/$/, "");

      const liveRoutes = new Set(routesPaths.map((route) => route.replace(/\/$/, "")));
      const live = localeFiltered.filter((post) =>
        liveRoutes.has(withBaseUrl(post.permalink)),
      );

      console.log(
        `[markdown-export] Mirroring ${live.length}/${posts.length} blog posts to Markdown…`,
      );

      const unknownAll = new Set();
      const indexable = [];
      let failed = 0;

      for (const post of live) {
        let markdown;
        try {
          const result = await mdxToMarkdown(post.raw, {
            currentFileDir: post.currentFileDir,
            projectRoot: siteDir,
          });
          markdown = buildHeader(post, siteConfig) + result.markdown;
          for (const name of result.unknownComponents) unknownAll.add(name);
        } catch (err) {
          // One broken article must never take the whole `yarn build` down —
          // the "one rule" in degrade.cjs already prevents almost every crash;
          // this is the last-resort net for whatever it doesn't catch.
          failed += 1;
          console.error(
            `[markdown-export] Failed to degrade ${post.file}: ${err.message}`,
          );
          continue;
        }

        const mirrorPath = path.join(outDir, `${post.permalink.replace(/\/$/, "")}.md`);
        fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
        fs.writeFileSync(mirrorPath, markdown, "utf-8");

        if (!post.draft && !post.unlisted) {
          indexable.push({ ...post, markdown });
        }
      }

      const seriesFiles = writeSeriesFull(outDir, indexable);
      writeLlmsTxt(outDir, siteConfig, indexable, seriesFiles);

      if (unknownAll.size > 0) {
        console.warn(
          `[markdown-export] ${unknownAll.size} component(s) had no explicit degrade rule and fell back ` +
            `to the generic "keep children, drop wrapper" behavior — add a rule in ` +
            `plugins/markdown-export-plugin/degrade.cjs's COMPONENT_RULES if this is a real gap:\n` +
            [...unknownAll]
              .sort()
              .map((name) => `  - ${name}`)
              .join("\n"),
        );
      }

      console.log(
        `[markdown-export] Done — ${indexable.length} article(s) in /llms.txt, ` +
          `${live.length - failed}/${live.length} mirrored, ${failed} failed.`,
      );
    },
  };
};
```

Enregistrez-le comme n'importe quel autre plugin :

```javascript title="docusaurus.config.js"
plugins: [
  "./plugins/markdown-export-plugin/index.cjs",
  // ...
],
```

> **Auto-hébergé ? Attention au type MIME:**
>
> `/blog/my-slug` (comme `/blog/docusaurus-llms-txt` pour cet article) est un *répertoire* contenant `index.html`, donc son voisin `/blog/my-slug.md` ne crée aucun conflit de route — celui-là est gratuit. Mais Apache n'a aucun mapping MIME natif pour `.md`, donc sans `AddType text/markdown .md` dans `.htaccess`, le navigateur propose un téléchargement au lieu d'afficher le fichier.

## La seule règle qui rend tout ça sûr {#the-one-rule-that-makes-this-safe}

**Un composant inconnu ne fait jamais échouer l'export. Son wrapper est supprimé et ses enfants prennent sa place.** Aucune exception, aucune erreur, jamais.

C'est cette unique règle qui permet à une table de dégradation de 900 lignes de vivre dans un pipeline de build sans devenir un risque. Ajoutez un composant dans six mois, oubliez d'écrire sa règle, et le pire cas est que son wrapper disparaisse alors que son contenu survit.

Mais un repli silencieux est une fuite lente : le plugin enregistre donc chaque composant qu'il n'a pas reconnu et avertit une fois par build. **C'est cet avertissement, pas la table, qui maintient la couverture à 100 % dans le temps** — la table est figée le jour où vous l'écrivez ; l'avertissement remarque le jour où vous la cassez.

## Rendre le tout découvrable {#making-it-discoverable}

Générer un fichier n'est pas le publier : sans lien qui pointe vers lui, personne — crawler ou humain — ne trouve `/llms.txt` ou un bundle de série tout seul. Quatre points d'accroche comblent ce manque, chacun visant un consommateur différent :

**1. `llms.txt` référence ses propres bundles.** L'index s'ouvre sur une section « Series (full-text bundles) ». C'est le seul endroit où ils sont référencés.

**2. Un `<link rel="alternate">` à l'échelle du site,** dans `headTags`, pour qu'il soit rendu côté serveur sur chaque page — exactement la façon dont on a toujours découvert le RSS :

```javascript title="docusaurus.config.js"
{
  tagName: "link",
  attributes: {
    rel: "alternate",
    type: "text/markdown",
    href: "https://www.avonture.be/llms.txt",
    title: "llms.txt — full site index in Markdown, for LLMs and readers",
  },
},
```

**3. Un équivalent par article,** pointant vers le miroir de cet article, injecté par un composant minuscule branché sur la page d'article :

**src/components/MarkdownAlternate/index.tsx**

```typescript
// Points crawlers/tools that scan <link rel="alternate"> at this exact
// article's plain-Markdown mirror (written by plugins/markdown-export-plugin,
// see src/components/CopyAsMarkdown for the human-facing equivalent). Same
// discovery pattern as RSS's <link rel="alternate" type="application/rss+xml">,
// scoped to a single page instead of the whole site — the site-wide
// counterpart (pointing at /llms.txt) lives in docusaurus.config.js's
// headTags, since that one applies to every page, not just blog posts.
import type { JSX } from "react";
import Head from "@docusaurus/Head";
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
import { translate } from "@docusaurus/Translate";

export default function MarkdownAlternate(): JSX.Element {
  const { metadata } = useBlogPost();

  return (
    <Head>
      <link
        rel="alternate"
        type="text/markdown"
        href={`${metadata.permalink}.md`}
        title={translate({
          id: "markdownAlternate.title",
          message: "Plain-Markdown mirror of this article",
        })}
      />
    </Head>
  );
}
```

**4. Un fil d'Ariane dans `robots.txt`.** Il n'existe aucune directive standard pour ça — c'est un commentaire, lu par tout ce qui parse déjà ce fichier et en cherche un. Ça a coûté deux lignes.

> **Une limitation, honnêtement:**
>
> Le lien destiné aux humains « View this series as plain Markdown » sur `/series/<slug>` est invisible pour un crawler qui n'exécute pas JavaScript : cette page est une route React Router côté client, donc son lien n'apparaît jamais dans le HTML rendu côté serveur. Les deux balises `<link rel="alternate">` ci-dessus n'ont pas ce problème — et c'est précisément pour ça que les points de découverte qui comptent vivent dans `headTags` et dans un composant, pas dans le corps d'une page.

Et puis il y a la partie qu'aucun code ne fera pour vous : **soumettre aux annuaires**. Les bons sont ceux vers lesquels la page officielle de la spec renvoie. J'ai réussi à m'enregistrer sur `llmstxt.site` et `directory.llmstxt.cloud` ; `llmstxthub.com` avait un formulaire de soumission cassé le jour où j'ai essayé. Dix minutes bien investies.

## Conclusion {#conclusion}

Ce que j'aime dans le résultat a moins à voir avec l'adoption par les IA que je ne l'imaginais au départ :

* **N'importe qui peut faire un `curl` sur un article et l'obtenir en entier.** Pas seulement un bot — un script, un collègue, moi sur une connexion lente — obtient le texte complet, sans HTML, sans JavaScript, sans navigation à nettoyer à la main d'abord.
* **Le blog est bot-friendly, que `llms.txt` prenne ou non.** Une version en Markdown pur de chaque article existe maintenant comme effet de bord, indépendamment du sort de cette convention.
* **Un fichier Markdown tombe directement dans un éditeur de texte ou dans la fenêtre de contexte d'un LLM.** Plus besoin de tout sélectionner et de supprimer la barre de navigation avant qu'un article soit réellement utilisable ailleurs.
* **`/llms.txt` fait aussi office d'inventaire du blog** — chaque article, une ligne chacun, groupé par sujet. Une table des matières que je n'avais pas avant, générée gratuitement.
* **Les bundles par série transforment 23 requêtes en une.** Pratique pour un assistant qui récupère du contexte, tout aussi pratique pour un lecteur qui veut une série entière dans un seul fichier, à lire ou à archiver.
* **Rien de tout ça n'a besoin d'être retenu.** C'est une étape de build, pas une habitude — et c'est exactement pour ça qu'une convention non prouvée méritait une heure d'essai.

Si les 107 accordéons [`<Snippet>`](/blog/docusaurus-snippets) fermés qui ont déclenché toute cette histoire sont nouveaux pour vous, ce composant mérite sa propre lecture.
