<!--
  canonical-url: https://www.avonture.be/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.
-->

# Your Blog Is Unreadable to an AI. Here Is the Fix.

> A growing share of your readers arrive through an AI assistant — and what they get is your HTML, wrapped in navigation, React widgets and collapsed accordions. This article generates a plain-Markdown mirror of every article, a /llms.txt index, and per-series full-text bundles, by degrading the MDX source rather than converting the rendered HTML. Includes the fallback rule that makes the export impossible to break.

---
![Your Blog Is Unreadable to an AI. Here Is the Fix.](/img/v2/llms-txt.webp)

**TL;DR**

`llms.txt` is a proposed convention for making a site legible to AI assistants — and an unproven one: no major assistant (ChatGPT, Claude, Perplexity) publicly confirms reading it. I explored it anyway, mostly because this blog is already written with Claude Code doing most of the file work, so maintaining a `llms.txt` mirror costs almost nothing. What I found while building it: the published HTML, wrapped in navigation, React widgets, and 107 collapsed `<Snippet>` accordions, is genuinely *less complete* than the source I wrote. The fix is a build-time plugin that emits a plain-Markdown mirror of every article, a `/llms.txt` index and one full-text bundle per series. The key decision: degrade the **MDX source**, never the rendered HTML — and never let an unknown component fail the export.

I wanted to explore the `llms.txt` track, mostly out of curiosity, not because I had any evidence readers were arriving through an assistant. The idea — proposed by [Answer.AI's Jeremy Howard](https://www.answer.ai/posts/2024-09-03-llmstxt.html) in September 2024 — is to publish a plain-Markdown index of a site at `/llms.txt`, so an assistant does not have to fight a React app to find the actual content. It is far from an established practice: none of ChatGPT, Claude, or Perplexity publicly confirm that their retrieval pipeline reads the file, and it may never catch on the way `robots.txt` or `sitemap.xml` did. What tipped it for me is that this blog is already written with Claude Code doing most of the file work, so keeping a `llms.txt` mirror in sync would not be a chore I carry by hand — it would just be another build step. When trying something costs that little, you just try it.

## What Gets Generated

One `yarn build`, three artifacts, all static:

```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.
```

The first is `/llms.txt` (see mine [online](https://www.avonture.be/llms.txt))— the site index, in the format [llmstxt.org](https://llmstxt.org) proposes: a title, a one-line summary, and then every article as a link plus its description, grouped by topic:

```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 …
```

The second is a plain-Markdown mirror of every single article, at its own URL plus `.md`. Take the [scroll-to-top button article](/blog/docusaurus-go-top), fetch [`/blog/docusaurus-go-top.md`](https://www.avonture.be/blog/docusaurus-go-top.md), and you get this — no JSX, no accordions, every code file inlined in full:

**/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:

…
```

The third is the one I would not have thought of on my own: **one full-text bundle per [series](/blog/docusaurus-series)**, at `/llms/<series-slug>.txt`, concatenating every article of that series in reading order. The [Docusaurus components](https://www.avonture.be/llms/creating-docusaurus-components.txt) bundle is 710 KB of pure Markdown — 23 articles that an assistant can pull in a single request instead of 23.

## Why the Source, and Never the HTML

Every tool in this space converts rendered HTML back to Markdown. That is the wrong direction, and the reason is not aesthetic:

* **The HTML has already lost information.** This blog folds 107 code snippets into collapsed `<Snippet>` accordions, some components render nothing until hydration, and images sit behind lazy loading — an HTML-to-Markdown pass can only recover what the HTML contains, and the HTML contains less than the source.
* **The source is already Markdown.** An MDX file is Markdown with some JSX sprinkled in it. Going source → Markdown means handling a few dozen component names; going HTML → Markdown means reverse-engineering an entire rendering pipeline.
* **The generated file is genuinely better than the page.** `<Snippet source="./files/compose.yaml">` becomes a fenced code block containing the whole file. A reader of the mirror gets the code the reader of the page has to click to reveal.
* **Nothing is invented.** Every component on this blog either wraps content the author typed, or points at a file on disk. There is no component that fabricates content at runtime — so a source-level degradation can be complete by construction.

The result is not a lossy export of my site. It is a *more complete* version of it.

## Building It

### The degradation table

My articles are not plain Markdown: they are MDX, Markdown with a few dozen custom React components mixed in — `<Snippet>` for a collapsible code block, `<AlertBox>` for a callout, `<Link>` for a cross-reference, and so on. Each one exists to add a bit of interactivity to the page, and each one is exactly what a Markdown mirror cannot render as-is. So the plugin has to know, one component at a time, what plain Markdown it should turn into. The whole thing is a remark pipeline: parse the MDX source into an AST, walk it, replace each JSX node with plain-Markdown nodes, then stringify. Each component gets one rule:

| Component                                                     | Uses | Degradation                                                       |
| ------------------------------------------------------------- | ---: | ----------------------------------------------------------------- |
| `Snippet`                                                     |  917 | fenced block; `source=` (841×) resolved and inlined in full       |
| `Link`                                                        |  779 | `[text](href)`                                                    |
| `AlertBox`                                                    |  546 | `> **{title}:** …` (variant becomes the keyword)                  |
| `Terminal`                                                    |  455 | ` ```bash ` block; `source=` (146×) resolved                      |
| `TLDR`                                                        |  253 | `> **TL;DR** …`                                                   |
| `BrowserWindow`                                               |   98 | children, plus a `> Screenshot — {url}` caption                   |
| `StepsCard`                                                   |   55 | ordered list                                                      |
| `ProjectSetup`                                                |   28 | `### Project: {folderName}` then each `Snippet` as a titled block |
| `Details`                                                     |   18 | left as `<details>` — valid Markdown already                      |
| `Prerequisite`                                                |   10 | bullet list                                                       |
| `Columns` / `Column`                                          |    9 | sequential sections                                               |
| `Reaction`, `ScrollToTopButton`, `Bluesky`, `RelatedPosts`, … | \~15 | **removed** — interface, not content                              |
| `Trees` / `Folder` / `File`                                   |    0 | **nothing to do** (see below)                                     |

That last row is my favorite line in the whole plugin. Those components exist because a remark plugin turns an ASCII directory tree in the source into React components. Not applying that plugin gives the original ASCII tree back for free. The correct rule was to write no rule at all.

**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 };
````

### The orchestrator

The plugin runs in `postBuild`, degrades each live article, writes the mirrors, then builds the two indexes on top of whatever succeeded:

**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.`,
      );
    },
  };
};
```

Register it like any other plugin:

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

> **Self-hosted? Watch the MIME type:**
>
> `/blog/my-slug` (like `/blog/docusaurus-llms-txt` for this article) is a *directory* containing `index.html`, so its sibling `/blog/my-slug.md` creates no route conflict — that one is free. But Apache has no built-in MIME mapping for `.md`, so without `AddType text/markdown .md` in `.htaccess`, the browser offers a download instead of displaying the file.

## The One Rule That Makes This Safe

**An unknown component never fails the export. Its wrapper is dropped and its children take its place.** No exceptions, no errors, ever.

That single rule is what allows a 900-line degradation table to sit in a build pipeline without becoming a liability. Add a component in six months, forget to write a rule, and the worst case is that its wrapper vanishes while its content survives.

But a silent fallback is a slow leak, so the plugin records every component it did not recognize and warns once per build. **That warning, not the table, is what keeps coverage at 100% over time** — the table is frozen the day you write it; the warning notices the day you break it.

## Making It Discoverable

Generating a file is not publishing it: without a link pointing at it, nothing — crawler or human — ever finds `/llms.txt` or a per-series bundle on its own. Four hooks close that gap, each targeting a different consumer:

**1. `llms.txt` links its own bundles.** The index opens with a "Series (full-text bundles)" section. It is the only place they are ever referenced.

**2. A site-wide `<link rel="alternate">`,** in `headTags`, so it is server-rendered on every page — exactly how RSS has always been discovered:

```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. A per-article equivalent,** pointing at that article's own mirror, injected by a tiny component wired into the blog post page:

**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. A breadcrumb in `robots.txt`.** There is no standard directive for this — it is a comment, read by anything already parsing that file and looking for one. It cost two lines.

> **One honest limitation:**
>
> The human-facing "View this series as plain Markdown" link on `/series/<slug>` is invisible to a crawler that does not run JavaScript: that page is a client-side React Router route, so its link never appears in server-rendered HTML. The two `<link rel="alternate">` tags above do not have that problem — which is exactly why the discovery hooks that matter live in `headTags` and in a component, not in a page body.

And then there is the part no code can do for you: **submitting to the directories**. The correct ones are the ones the official spec page links to. I got `llmstxt.site` and `directory.llmstxt.cloud` registered; `llmstxthub.com` had a broken submission form the day I tried. Worth ten minutes.

## Conclusion

What I like about the result has less to do with AI adoption than I expected going in:

* **Anyone can `curl` an article and get it whole.** Not just a bot — a script, a colleague, me on a slow connection — gets the full text with no HTML, no JavaScript, no navigation to strip out by hand first.
* **The blog is bot-friendly regardless of whether `llms.txt` itself ever catches on.** A plain-Markdown version of every article now exists as a side effect, independent of that convention's fate.
* **A Markdown file drops straight into a text editor or an LLM's context window.** No more select-all-and-delete-the-navbar before an article is actually usable somewhere else.
* **`/llms.txt` doubles as an inventory of the blog** — every article, one line each, grouped by topic. A table of contents I did not have before, generated for free.
* **The per-series bundles turn 23 requests into one.** Handy for an assistant pulling context, just as handy for a reader who wants an entire series in a single file to read or archive.
* **None of it needs to be remembered.** It is a build step, not a habit — which is exactly why an unproven convention was worth one hour to try in the first place.

If the 107 collapsed [`<Snippet>`](/blog/docusaurus-snippets) accordions that started this whole thing are new to you, that component is worth its own read.
