# Creating Docusaurus components A series of 26 article(s), in reading order. # Change how Docusaurus will create img tags > Customize the way Docusaurus generates tags to better control image rendering and behavior. --- ![Change how Docusaurus will create img tags](/img/v2/docusaurus_component.webp) **TL;DR** This post explains how to customize image rendering in Docusaurus by creating a custom `` React component. You'll learn how to build the component, style it with CSS, and then use a custom Remark plugin to automatically replace standard Markdown images with your new component. The guide also covers how to configure the plugin to skip the first image in a post and how to register it in your Docusaurus configuration, giving you full control over how images are displayed. I'll start a series about writing components for Docusaurus. Since it's always nice to start with something very concrete and practical, here's what we're going to do: we'll intercept the conversion from `![Alt text](./img/example.webp)` to a HTML `` tag. We'll force Docusaurus to use instead a component `` from our own. We'll customize our `` tag to first inject a `
` parent, we'll force our `` with attributes like CSS, lazy loading, ... **but, not for all tags in the blog post since we'll skip the first one.** Indeed, the blog post introduction image should remains unchanged; we'll start to update the other images. And here is the result of our own, self-made, `` component: (image: A happy meerkat) As you can see, the image is centered, has a hover effect, rounded corners, ... If you look at the HTML code, you'll see the `loading="lazy"` attribute. That's pretty cool: our component is working! Let's build it. We need to do several things so let's start with the first one: we need to create our `` component. ## 1. Creation of our Image component Writing a component for Docusaurus is quite easy. You should create a new folder in the existing `src/components` one. In this article, we'll create a `Image` component so let's create the `src/components/Image/index.tsx` file with this content: **src/components/Image/index.tsx** ```typescript /** * 🖼️ Image Component * * A lightweight wrapper for rendering images in Docusaurus with base URL resolution. * Ensures that image paths are correctly resolved relative to the site's base URL, * making it ideal for static assets stored in the `static/img` directory. * * 🔍 Behavior: * - Uses `useBaseUrl()` to resolve the image path * - Applies scoped styling via CSS modules * - Supports optional `alt` and `title` attributes for accessibility and tooltips * - Enables lazy loading for performance optimization */ import type { JSX } from "react"; import styles from "./styles.module.css"; import useBaseUrl from "@docusaurus/useBaseUrl"; interface Props { /** Path to the image (e.g. `/img/example.png`), or a `require()`d asset */ src: string | { default: string }; /** Tooltip text shown on hover */ title?: string; /** Alternative text for accessibility (if missing, reuse the Title property) */ alt?: string; } export default function Image({ src, alt, title }: Props): JSX.Element { const isAbsolutePath = typeof src === "string" && src.startsWith("/"); const resolvedUrl = useBaseUrl(isAbsolutePath ? src : undefined); const imgSrc = (isAbsolutePath ? resolvedUrl : src) as string; return (
{alt
); } ``` Our `Image` component will ask three parameters but just the `img` one is required. We can pass too a title (`title`) and the alternate text (`alt`). As you can easily understand, the `` component will just create a `div` where an `img` tag will be present. Nothing fancy except we'll use some CSS for the look\&feel and the fact we'll force a [lazy loading](/blog/docusaurus-lazy-loading). Please also create the `src/components/Image/styles.module.css` file: **src/components/Image/styles.module.css** ```css .container { text-align: center; padding: 10px; background: rgb(255, 255, 255, 0.15); border: 1px solid rgb(255, 255, 255, 0.3); border-radius: 16px; backdrop-filter: blur(10px) saturate(180%); } .container img { border-radius: 12px; box-shadow: 0 8px 20px rgb(0, 0, 0, 0.15); transition: transform 0.4s ease, box-shadow 0.4s ease, filter 0.4s ease; } .container img:hover { transform: scale(1.05) translateY(-4px); box-shadow: 0 12px 25px rgb(0, 0, 0, 0.25); filter: brightness(1.05) saturate(1.1); } ``` ## 2. Tell Docusaurus about our component Before being able to use the `` component, we should tell Docusaurus what to do when it'll see it. Please also edit the `src/theme/MDXComponents.js` file (and if not present, please create it) **src/theme/MDXComponents.js** ```javascript import MDXComponents from "@theme-original/MDXComponents"; import Image from "@site/src/components/Image"; export default { // Reusing the default mapping ...MDXComponents, Image, }; ``` Everything is now in place. ## 3. Using the Image component From now, in a blog post, instead of writing ```html ![A happy meerkat](./images/happy.webp) ``` we can write ```html ``` The `require` part is needed because the image is located in a subfolder of the blog post. That's exactly the tag rendered at the top of this article. If the source was something like `/img/happy.jpg` then the code is easier: ```html ``` > **Caution:** > > But, uh oh, on this blog, I've more than 250 articles right now (August 2025), I'm not going to go through all my articles to replace my images, am I? > > But also, maybe tomorrow I don't want this tag anymore, so I want to keep my articles in **vanilla Markdown** (standard language); how can I do that? The answer is: just write a plugin (I've used the very same trick for my [search\&replace plugin](/blog/docusaurus-plugin-replace)). The plugin will be able to intercept the Markdown code of the article and manipulate it. That's exactly what we want here! We'll intercept the conversion of images from Markdown to HTML and tell Docusaurus to use our Image tag instead of img. ## 4. Writing and registering a plugin (optional — only if you have hundreds of articles like me) If you only have a handful of posts, it's simpler to just edit them by hand and skip this section. So we need a plugin to intercept the conversion from `![Alt text](./img/example.webp)` to a HTML image tag; automatically. > **Caution:** > > But let's add a complexity: we don't want to change the first article i.e. the blog post introduction image. For that first image, let Docusaurus do its job (it's doing this really fine). We'll intercept as from the second image of the blog post. Please create the `plugins/remark-image-transformer/index.cjs` file: **plugins/remark-image-transformer/index.cjs** ```cjs /** * @fileoverview * A Remark plugin for transforming Markdown image nodes (`![alt](url)`) into MDX-compatible `` components. * This plugin is tailored for Docusaurus environments, where image paths often need to be resolved using `require()` or `useBaseUrl()`. * It supports: * - Relative paths (e.g. `./image.png`) → converted to `require()` expressions * - Absolute paths (e.g. `/img/banner.png`) → passed directly, as Docusaurus resolves them * - External URLs → passed directly as string literals * Additionally, it preserves `alt` and `title` attributes. * * @module remarkReplaceImgToImage */ // cspell:ignore mdxjs const { visit } = require("unist-util-visit"); const acorn = require("acorn"); /** * Parses a JavaScript expression string into an ESTree-compatible expression node. * * @param {string} expression - A valid JavaScript expression as a string. * @returns {object} ESTree expression node representing the parsed expression. */ function expressionToEstree(expression) { return acorn.parse(expression, { ecmaVersion: 2020, sourceType: "module", }).body[0].expression; } /** * Creates an MDX JSX attribute node with an embedded JavaScript expression. * * @param {string} name - The name of the JSX attribute (e.g. "src"). * @param {string} expr - A JavaScript expression string to embed in the attribute. * @returns {object} MDX AST node representing the JSX attribute with expression value. */ function makeJsxAttributeExpression(name, expr) { return { type: "mdxJsxAttribute", name, value: { type: "mdxJsxAttributeValueExpression", value: expr, data: { estree: { type: "Program", body: [ { type: "ExpressionStatement", expression: expressionToEstree(expr), }, ], sourceType: "module", }, }, }, }; } /** * Remark plugin that replaces Markdown image nodes with MDX `` components. * * @param {object} [options={}] - Plugin configuration options. * @param {boolean} [options.skipFirst=true] - Whether to skip transforming the first image node. * @returns {function} Transformer function to be used by Remark. */ function remarkReplaceImgToImage({ skipFirst = true } = {}) { return (tree) => { let imageCount = 0; visit(tree, "image", (node, index, parent) => { imageCount++; if (skipFirst && imageCount === 1) return; const url = node.url || ""; const attributes = [ { type: "mdxJsxAttribute", name: "alt", value: node.alt || "" }, ]; if (node.title) attributes.push({ type: "mdxJsxAttribute", name: "title", value: node.title, }); if (/^\.+\//.test(url)) { // Relative to MD file -> use require() const expr = `require("${url}").default`; attributes.unshift(makeJsxAttributeExpression("src", expr)); } else if (/^\//.test(url)) { // Absolute -> just pass the path, Docusaurus will handle it. attributes.unshift({ type: "mdxJsxAttribute", name: "src", value: url, }); } else { // Fallback: external URL attributes.unshift({ type: "mdxJsxAttribute", name: "src", value: url, }); } const imageNode = { type: "mdxJsxFlowElement", name: "Image", attributes, children: [], }; parent.children.splice(index, 1, imageNode); }); // The useBaseUrl import is no longer needed. }; } module.exports = remarkReplaceImgToImage; ``` *This plugin is more complex because we need to make sure paths to images are correctly handled (we use relative paths like `![Example image](./img/example.webp)` or absolute (`![Example image](/img/example.webp)`) or even external ones).* Once the plugin has been created, we need to update the Docusaurus configuration to load it. To do this, edit your `docusaurus.config.js` file and add the highlighted lines as illustrated below. **docusaurus.config.js** ```javascript const config = { // [ ... ] presets: [ [ 'classic', ({ blog: { // highlight-next-line beforeDefaultRemarkPlugins: [require('./plugins/remark-image-transformer')], }, // [ ... ] }), ], ], }; export default config; ``` Now restart your Docusaurus server (so the plugin can be registered). If everything is correctly in place, open any of your previous blog post and you'll see it's working. ## Conclusion Now, up to you to edit the `src/components/Image/styles.module.css` file and use your own CSS. And once every image goes through your own component, it becomes easy to audit them: see [Running some checks on your Docusaurus images](/blog/docusaurus-check-images). --- # Overrides the generation of img tags with Docusaurus > Learn how to customize the rendering of tags in Docusaurus for enhanced control over image output. --- ![Overrides the generation of img tags with Docusaurus](/img/v2/docusaurus_component.webp) **TL;DR** This article provides a simple solution for enabling lazy loading for images in Docusaurus to improve performance. It explains how to intercept the default rendering of `` tags by customizing the `src/theme/MDXComponents.js` file. By adding a simple mapping, you can force all images to be lazy-loaded and even apply custom styles, without needing to create a complex component or plugin. While searching for good tips & tricks about Docusaurus when the number of articles become important, I read that we need to pay attention to lazy load images and, indeed, by default all images are loaded when a blog post is first accessed. In this article, we'll see how easily we can intercept the creation of the `` tag when the Markdown content is converted to HTML. This conversion occurs at two places: when you're surfing on your website or when you're generating a static version of it. To do this, in fact, we don't need to create a component! We just need to add some lines of code in the `src/theme/MDXComponents.js` file. *If you need more than a couple of extra attributes — a wrapper `div`, custom CSS, a zoom effect — then a real component is the way to go; that's the subject of [Change how Docusaurus will create img tags](/blog/docusaurus-override-img).* If you don't have that file yet, just create it. > **Note:** > > The `// [...]` notation is there as a placeholder to show you that perhaps you'll have already some lines of code there. Don't delete it; just append the highlighted lines as shown below. **src/theme/MDXComponents.js** ```javascript import React from "react"; import MDXComponents from "@theme-original/MDXComponents"; // [...] export default { // Reusing the default mapping ...MDXComponents, // highlight-start img: (props) => { const { loading, decoding, className, style, height, width, ...rest } = props; return ( ); }, // highlight-end // [...] }; ``` Once added to your `MDXComponents.js` file, just return to the browser; open any post and refresh the page. You'll see that images have now the `loading="lazy"` attribute (you can verify using the dev tools panel of your browser). If like in the example given here above, you've added some CSS style; you'll see it immediately. Easy no? Now, how do you make sure it stays true across two hundred articles? By checking it automatically: see [Running some checks on your Docusaurus images](/blog/docusaurus-check-images). --- # Organize Your Docusaurus Content with a Custom Series Component > Learn how to group related Docusaurus blog posts into a seamless reading experience using a custom Series component. --- ![Organize Your Docusaurus Content with a Custom Series Component](/img/v2/docusaurus_component.webp) **TL;DR** This in-depth guide teaches you how to implement a complete 'blog post series' feature in Docusaurus. You'll learn to create a `` component that displays a series banner on individual posts, and a dedicated `/series` page that lists all your series as cards. The tutorial covers creating multiple React components, utility helpers for fetching post data, overriding Docusaurus theme components via swizzling, and building a custom local plugin to handle dynamic series routes. Finally, it shows how to add custom descriptions and images to your series using a centralized data file. If you have been using Docusaurus for a long time, you may have noticed that it is not possible to link articles together as a series. It would be nice to write a first article, a second, a third, and so on, and be able to tell Docusaurus that these articles are part of the same series. This will also positively impact your SEO (by creating better internal linking) and keep your readers engaged for longer. *For articles that are related but don't form an ordered series, [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts) plays the same role based on shared tags.* This isn't possible natively, so let's create a component for it. > **Spoiler alert:** > > Just click on the [Series](/series) link to see how cool the **SeriesPosts** component is. You will learn how to do this here. Here is the component in action, once wired up — a banner announcing the series, right on the article: ![Our component is now running](./images/our-component-is-running.webp) Part 2 below goes further and adds a full `/series` page listing every series as cards: ![A page will all series](./images/series-page.webp) Let's build both, starting with the banner. > **Information:** > > Have you seen this article before? Look at the top. You will see a banner that says, 'This article is part of the **Creating Docusaurus components** series.' This is exactly what we will learn how to do right now. This long article has two main sections: one on the actual creation of the component and the other on navigating the blog. *It combines the two techniques used all over this series: swizzling a theme component (as in [the "over a year old" notice](/blog/docusaurus-old-notice)) and writing a local plugin (as in [the search\&replace plugin](/blog/docusaurus-plugin-replace)).* ## Part 1 - Creation of the SeriesPosts component In this first chapter, we will create our `SeriesPosts` component so that when you open an article, you will see a 'This article is part of the...' message and a list of other articles in that series. **This implies several things:** 1. We will need some helpers. 2. We will need a new component. 3. Blog posts will need a `series` item in their YAML front matter, and 4. We will have to override the standard way of displaying a blog post. ### 1.1. We need some helpers If you do not have it yet, please create the `src/components/Blog/utils/posts.ts` file. It will contain a helper function that we can reuse for several components. That helper will scan each Markdown file with an `.md` or `.mdx` extension in the `blog` subfolder. For each file, the script will look at the YAML front matter and use some of its properties. For example: * If the blog post has `draft: true` or `unlisted: true`, the post will be ignored. * If the blog post has a `slug`, it will be used; otherwise, the slug will be generated by the code. * If the blog post has an associated image, it will be used; otherwise, a default image will be used. * Then, the helper will return the list of posts and their properties. Some properties are custom, like `mainTag` (see my [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts) article) and, for our current needs, the `series` property. So, just copy and paste the content of the file below and create the `src/components/Blog/utils/posts.ts` file in your project's structure. **src/components/Blog/utils/posts.ts** ````typescript /** * getBlogMetadata * * Extracts metadata from all MDX blog posts located in the `/blog` directory. * Uses Webpack's `require.context` to dynamically load and parse frontmatter * from each post, returning a structured array of metadata objects. * * Behavior: * - Resolves permalinks based on `slug` or folder structure * - Normalizes image paths for static assets * - Filters out invalid or missing entries * * Returned metadata includes: * - `title`: Post title * - `description`: Short summary * - `image`: Resolved image path * - `draft`: Boolean flag for unpublished posts * - `unlisted`: Boolean flag for hidden posts * - `permalink`: URL path to the post * - `tags`: Array of tags * - `mainTag`: Primary tag (optional); used by the RelatedBlogPost component * - `authors`: Array of author names * - `date`: Publication date * - `series`: Series name (optional); used by the SeriesBlogPost component * * Usage: * ```ts * import { getBlogMetadata } from './posts'; * const posts = getBlogMetadata(); * const withDrafts = getBlogMetadata({ includeDrafts: true }); * ``` * * By default, posts flagged `draft` or `unlisted` are excluded so consumers don't have to * remember to filter them out themselves. Pass `includeDrafts`/`includeUnlisted` to opt back in * (e.g. author-facing counters that need to show "in progress" articles). * * Note: * This function is intended for use in static site generation or client-side rendering * where Webpack's `require.context` is available. */ /** * A tag as it reaches these helpers: a plain slug string from front matter, or — * defensively, since related.ts and TagArticlesPage both handle it — a * Docusaurus-normalised tag object. */ export type BlogTag = string | { label: string }; export interface BlogPostMetadata { authors: string[]; blueskyRecordKey: string | null; /** * ISO date string from front matter, e.g. "2026-08-28". Every article under blog/ carries * one (blog authoring convention, and the blog plugin itself refuses a post without a * date), so this is not optional. * * Runtime note: plugins/frontmatter-loader actually hands back a real `Date` instance for * a typical unquoted YAML date (see that plugin's own header comment) — not a string. * Declaring `string` here anyway matches the pre-existing contract every niveau 1-4 `.tsx` * consumer already compiles against (`Post.date?: string`, …); every one of them passes * this straight into `new Date(...)`, which accepts a `Date` argument just as well as a * string, so nothing behavioral changes. Widening this to the more literally accurate type * would ripple into every already-migrated consumer — out of scope here. */ date: string; description: string | undefined; draft: boolean; image: string | undefined; mainTag: string | null; permalink: string; series: string | null; tags: BlogTag[]; title: string; unlisted: boolean; } interface RawFrontMatter { title?: string; slug?: string; image?: string; authors?: string[]; blueskyRecordKey?: string; date?: BlogPostMetadata["date"]; description?: string; draft?: boolean; mainTag?: string; series?: string; tags?: BlogTag[]; unlisted?: boolean; } // The `!!…!` prefix is load-bearing, do not simplify it away: it bypasses the // rules configured by @docusaurus/plugin-content-blog and reads the files with // plugins/frontmatter-loader instead. Without it, Webpack MDX-compiles every // file under `blog/`, and any article flagged `draft: true` crashes the // production build with "Blog post not found for filePath=…" — because the blog // plugin drops drafts from its post list while this context still pulls them in. // See plugins/frontmatter-loader/index.cjs for the full story. import { useTranslationState, slugFromPermalink } from "./translations"; const posts = require.context( "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../blog", true, /\.mdx?$/, ); // Second corpus: the French translations. Needed because the context above reads `blog/` — the // English source — whatever locale is being rendered, so every card, list and related-post // block showed an English title on a French page even for articles that ARE translated. // // The locale is spelled out rather than computed: `require.context` is a webpack construct and // its path must be statically analysable. Adding a locale means adding a context here. const translatedPosts = require.context( "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../i18n/fr/docusaurus-plugin-content-blog", true, /\.mdx?$/, ); /** slug -> the translated `title`/`description`, for the one non-default locale we ship. */ function buildTranslatedFrontMatter(): Map { const bySlug = new Map(); for (const key of translatedPosts.keys()) { const post = translatedPosts(key) as { frontMatter: RawFrontMatter }; if (!post.frontMatter?.title) continue; const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, ""); const slug = post.frontMatter.slug ? post.frontMatter.slug.replace(/^\/+|\/+$/g, "").replace(/^blog\//, "") : dir.split("/").pop() ?? ""; if (slug) { bySlug.set(slug, { title: post.frontMatter.title, description: post.frontMatter.description ?? "", }); } } return bySlug; } const TRANSLATED_FRONT_MATTER = buildTranslatedFrontMatter(); export function getBlogMetadata({ includeDrafts = false, includeUnlisted = false, }: { includeDrafts?: boolean; includeUnlisted?: boolean } = {}): BlogPostMetadata[] { return posts .keys() .map((key): BlogPostMetadata | null => { const post = posts(key) as { frontMatter: RawFrontMatter }; // A Markdown file under `blog/` without a title is not an article // (a fragment, a note, a readme…); the blog plugin ignores it, so do we. if (!post.frontMatter?.title) { return null; } const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, ""); let permalink: string; if (post.frontMatter.slug) { permalink = post.frontMatter.slug.startsWith("/") ? post.frontMatter.slug : `/blog/${post.frontMatter.slug.replace(/^\//, "")}`; } else { permalink = `/blog/${dir}/`; } let imageUrl = post.frontMatter.image; if (imageUrl && imageUrl.startsWith("./")) { imageUrl = `/blog/${dir}/${imageUrl.replace("./", "")}`; } return { authors: post.frontMatter.authors || [], blueskyRecordKey: post.frontMatter.blueskyRecordKey || null, date: post.frontMatter.date as BlogPostMetadata["date"], description: post.frontMatter.description, draft: post.frontMatter.draft || false, image: imageUrl, mainTag: post.frontMatter.mainTag || null, permalink, series: post.frontMatter.series || null, tags: post.frontMatter.tags || [], title: post.frontMatter.title, unlisted: post.frontMatter.unlisted || false, }; }) .filter((post): post is BlogPostMetadata => post !== null) .filter( (post) => (includeDrafts || !post.draft) && (includeUnlisted || !post.unlisted), ); } /** * Locale-aware counterpart of `getBlogMetadata()` — the one every rendering surface should use. * * `getBlogMetadata()` reads `blog/` through `require.context`, so it always returns the **English** * corpus, whatever locale the page is being rendered in. Left unfiltered, a French listing shows * entries whose titles are English and whose pages open on English prose — which reads as a broken * site rather than as a partially translated one. * * On the default locale this is `getBlogMetadata()` unchanged, so switching a call site over is * always safe. * * Note this filters, it does not translate: the titles and descriptions of the surviving entries * still come from the English source. Showing the translated ones is a separate (larger) job — * see TODO 0119, lot C, "rendre le corpus de posts.ts conscient de la locale". */ export function useBlogMetadata( options: { includeDrafts?: boolean; includeUnlisted?: boolean } = {}, ): BlogPostMetadata[] { const { filterTranslated, isDefaultLocale } = useTranslationState(); const visible = filterTranslated(getBlogMetadata(options), (post) => slugFromPermalink(post.permalink), ); if (isDefaultLocale) return visible; // Overlay the translated title and description. Only these two are localized: `permalink`, // `image`, `tags`, `series` and `date` are shared with the English source by design — the // translation copies them byte for byte (see the translation contract in TODO 0119). return visible.map((post) => { const translated = TRANSLATED_FRONT_MATTER.get(slugFromPermalink(post.permalink)); if (!translated) return post; return { ...post, title: translated.title, description: translated.description || post.description, }; }); } ```` We will also need a second helper. Please create the `src/components/Blog/utils/slug.ts` file: **src/components/Blog/utils/slug.ts** ```typescript /** * Converts a string into a URL-friendly slug. * * This function: * - Converts the string to lowercase * - Normalizes accented characters (e.g., accented e to plain e) * - Removes diacritics and special characters * - Replaces spaces with hyphens * - Collapses multiple hyphens into one * - Trims leading and trailing hyphens * * @param text - The input string to convert. * @returns The slugified version of the input. */ export function createSlug(text: string): string { return text .replace(/([a-z0-9])([A-Z])/g, "$1-$2") // camelCase to kebab-case .toLowerCase() .normalize("NFD") // Decomposes accented characters .replace(/[\u0300-\u036f]/g, "") // Removes diacritics .replace(/[^a-z0-9\s-]/g, "") // Removes special characters .replace(/\s+/g, "-") // Replaces spaces with hyphens .replace(/-+/g, "-") // Collapses multiple hyphens .replace(/^-+|-+$/g, ""); // Trims leading/trailing hyphens } ``` ### 1.2. Our SeriesPosts component Now, we will create our component. Please create the `src/components/Blog/SeriesPosts/index.tsx` file with the following code: **src/components/Blog/SeriesPosts/index.tsx** ```typescript /** * 📚 SeriesPosts Component * * Displays a list of blog posts that belong to the same series. * Useful for linking related articles together in a Docusaurus blog. * * Behavior: * - Fetches blog metadata via `getBlogMetadata()` * - Filters posts by series name * - Sorts posts chronologically * - Highlights or links each post depending on whether it's the current one * * Styling: * - Uses scoped CSS from `styles.module.css` * * Returns: * - A styled list of blog posts in the same series, or null if none found. */ import type { JSX } from "react"; import Link from "@docusaurus/Link"; import Translate from "@docusaurus/Translate"; import clsx from "clsx"; import Details from "@site/src/components/Details"; import { useBlogMetadata } from "@site/src/components/Blog/utils/posts"; import { createSlug } from "@site/src/components/Blog/utils/slug"; import { useSeriesLocalizer } from "@site/src/components/Blog/utils/seriesI18n"; import styles from "./styles.module.css"; interface Props { /** The name of the blog series to display */ series: string; /** Permalink of the current post to exclude from linking */ excludePermalink?: string | null; /** Whether to visually highlight the current post */ highlightCurrent?: boolean; } export default function SeriesPosts({ series, excludePermalink = null, highlightCurrent = true, }: Props): JSX.Element | null { const posts = useBlogMetadata() .filter((post) => post.series === series) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); // `series` is the English key — it matches the front matter and builds the slug. Only the link // text is localized, so a French article stops announcing an English series name. const seriesLabel = useSeriesLocalizer()(series)?.label ?? series; if (!posts.length) return null; return (
{seriesLabel} ), }} > {"This article is part of the {seriesLink} series:"} } >
    {posts.map((post) => { const isCurrent = post.permalink === excludePermalink; return (
  • {isCurrent ? ( {post.title} ) : ( {post.title} )}
  • ); })}
); } ``` This JavaScript code will retrieve the list of all blog posts. Then, it will extract a property called `series` (which may or may not be present in your blog post's YAML front matter). If a post has the `series` property, the script will compare its value with the value of the current blog post. If there is a match, we have other articles in the series, and the script will display 'This article is part of...' followed by the series name and the list of articles. Articles will be displayed in chronological order to allow the reader to follow the series in a logical order. The rest of the logic simply displays a bulleted list. And the last file to create is the CSS file: **src/components/Blog/SeriesPosts/styles.module.css** ```css /* * The "this post is part of a series" box. * * NO BACKGROUND TINT. That was tried and removed on review, and the reason is * worth keeping: the page ground is already cream (--mk-bg #fffdf7) and the * "key" tone is cream too (--mk-key-bg #faf0de). A tinted block on a tinted * page reads as a washed-out rectangle rather than as a distinct object — it * costs a lot of visual weight to say very little. The rail alone says * "this is an aside" perfectly well, and it is the same rail as the article's * own h2, so the box sits in the page's existing grid of left marks instead of * interrupting it. * * Keep this in mind for any future aside: on this site, tint is for callouts * that carry a WARNING or a KEY IDEA. A navigation aid gets a rail and nothing * more (see QuickJump, which made the same call). */ .seriesBlogPost { color: var(--mk-ink); border-left: var(--mk-rail) solid var(--mk-primary); padding: 0.25rem 0 0.25rem 1.25rem; margin-bottom: 2rem; } /* Applied to Details's via its summaryClassName prop — not a ".seriesBlogPost p" descendant selector, which composes can't use (single local class only) and which never matched anyway: the intro text renders inside , not a

, so the pre-harmonisation bold/italic/1.1rem rule here was dead CSS. */ .seriesIntro { composes: mk-eyebrow from global; cursor: pointer; } .seriesIntro:hover { color: var(--mk-primary); } .seriesBlogPost ul { list-style-type: none; padding-left: 0; counter-reset: post-counter; margin: 0.625rem 0 0; } .seriesBlogPost li { counter-increment: post-counter; display: flex; align-items: baseline; font-size: 0.95rem; line-height: 1.6; } .seriesBlogPost li:not(:last-child) { margin-bottom: 0.4rem; } .seriesBlogPost li::before { content: counter(post-counter); margin-right: 0.75rem; min-width: 1.1rem; font-weight: 700; font-size: 0.85rem; color: var(--mk-primary); font-variant-numeric: tabular-nums; } .seriesBlogPost li a, .seriesBlogPost li span { text-decoration: none; color: inherit; } .seriesBlogPost li a:hover { text-decoration: underline; text-underline-offset: 2px; color: var(--mk-primary); } .currentPost { font-weight: 700; color: var(--mk-primary); } .currentPostDimmed { opacity: 0.6; } /* * No dark-mode block: every value above is a token that flips on its own. * (The pre-harmonisation override pointed at --ifm-background-surface-color, * the CARD colour, which made this box indistinguishable from the article * around it in dark mode.) */ ``` ### 1.3. Editing our articles Now for the easy part: edit a few of your existing blog posts and add the `series` key to the YAML front matter. For example: **index.md** ```markdown --- title: Create your Joomla website using Docker - Part 1 [...] series: Create your joomla website using Docker [...] --- In this article, we will learn how to ... ``` Do this for a few posts so that you have a few articles in that series. > **Caution:** > > If you visit your blog right now and browse to your article, you will not see any difference at all. This is completely normal because we have just created a component; we still need to tell Docusaurus to use it. ### 1.4. Overriding the BlogPostItem template So, we need to call our newly created component. Of course, we could include the `` tag in each article, but we're smarter than that, aren't we? Let's create an override of the Docusaurus `BlogPostItem` template. In a terminal, run `yarn docusaurus swizzle @docusaurus/theme-classic BlogPostItem`, then select `Javascript`, `Eject`, and finally `YES`. Many files will be created in your Docusaurus project's `src/theme/BlogPostItem` folder. We can safely remove all files except for the `src/theme/BlogPostItem/index.js` file, which we need to edit. So, please remove any files or folders under `src/theme/BlogPostItem` except for the `index.js` file. In the code below, the highlighted lines are the ones we need to add. **src/theme/BlogPostItem/index.js** ```javascript import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import BlogPostItemContainer from "@theme/BlogPostItem/Container"; import BlogPostItemContent from "@theme/BlogPostItem/Content"; import BlogPostItemFooter from "@theme/BlogPostItem/Footer"; import BlogPostItemHeader from "@theme/BlogPostItem/Header"; import clsx from "clsx"; // highlight-next-line import SeriesPosts from "@site/src/components/Blog/SeriesPosts"; // apply a bottom margin in list view function useContainerClassName() { const { isBlogPostPage } = useBlogPost(); return !isBlogPostPage ? "margin-bottom--xl" : undefined; } export default function BlogPostItem({ children, className }) { // We need to retrieve the isBlogPostPage flag const { metadata, isBlogPostPage } = useBlogPost(); const containerClassName = useContainerClassName(); return ( // highlight-start {isBlogPostPage && ( )} // highlight-end {children} // highlight-start {isBlogPostPage && ( )} // highlight-end ); } ``` > **We need to restart Docusaurus:** > > Now, because we have just introduced an override, we need to restart our Docusaurus server so the changes can take effect. > **Run npm run start:** > > If you're running Docusaurus locally, just run `npm run start` in your console. > If, like me, you are running Docusaurus using Docker, just stop the container and start a new one. ### Let's see if it's working Return to your blog and refresh the page. You should see the same banner already teased at the top of this article (with your own content, of course). Great! The component is now running. We can create our series. In this example, I have edited three articles about Joomla and added the `series` key to their YAML front matter. You can navigate from one article to another. > **Information:** > > We could stop here, but that would be like completing only half the journey. We still need to implement a page that will display the list of series and, when a series is clicked, the list of articles in it. ## Part 2 - Adding a navigation to series As mentioned, we will create a `/series` page on our blog so we can immediately get access to all series. **So, we'll need to** 1. Create the /series page 2. Create a SeriesCards component 3. Create a PostCard component 4. Add a /series entry to our navigation bar and 5. Create a plugin to manage `/series/a-series-name` URLs. ### 2.1 Adding a new page to show all series It would be cool to have a page on our Docusaurus site to display the list of series, wouldn't it? Let's do it. First, please create the `src/pages/series.mdx` file with this content: **src/pages/series.mdx** ```mdx --- title: "All articles series" hide_table_of_contents: true image: "/img/series.webp" authors: [christophe] --- import "@site/src/css/series.css"; import SeriesCards from "@site/src/components/Blog/SeriesCards"; import SeriesStats from "@site/src/components/Blog/SeriesStats"; import TranslationCoverage from "@site/src/components/Blog/TranslationCoverage";

All Series

Each series has its own RSS feed —{" "} get the next episode automatically, without checking back.
Discover our collections of articles organized by theme. Each series offers you a progressive learning path to master a specific area of web development.
``` > **Caution:** > > As you can see, that page contains Markdown content with one special feature: it contains JavaScript code. This is why it is very important that the file extension is `.mdx`. As you can see, there is some Markdown content (adapt it to fit your needs) and the use of a new component, `SeriesCards`. Let's create it. ### 2.2 Creation of the SeriesCards component Please create the `src/components/Blog/SeriesCards/index.tsx` file with this content: **src/components/Blog/SeriesCards/index.tsx** ```typescript import { useMemo, type JSX } from "react"; import { useSeriesList } from "@site/src/components/Blog/utils/series"; import Translate from "@docusaurus/Translate"; import PostCard from "@site/src/components/Blog/PostCard"; import SERIES_DATA from "@site/src/data/series.js"; import styles from "./styles.module.css"; /** * SeriesCards component * * Renders a responsive grid of blog article series using metadata generated by `useSeriesList`. * Each card displays the series title, image, and a summary of published and draft articles. * Cards link to a dedicated page for each series. * * Layout: * - Uses Docusaurus Infima grid classes (`row`, `col--4`) for responsive layout. * - Falls back to a friendly message if no series are found. * * Dependencies: * - `useSeriesList` (utility to group and format blog posts by series) * - `PostCard` (component to render individual series cards) * * Location: src/components/Blog/SeriesCards/index.tsx */ const DEFAULT_IMAGE = "/img/default.webp"; export default function SeriesCards(): JSX.Element { const seriesList = useSeriesList(); const enriched = useMemo( () => seriesList.map((serie) => { const data = SERIES_DATA.find((s) => s.name === serie.seriesName); const imagePath = data?.image ?? DEFAULT_IMAGE; return { ...serie, counter: serie.counter, // SERIES_DATA is the ENGLISH source of truth; useSeriesList() only // returns a `description` when the current locale has a translated // row for this series. Reading SERIES_DATA first published English // descriptions under every /fr/series/ card. description: serie.description ?? data?.description, image: imagePath, title: serie.title, }; }), [seriesList], ); if (enriched.length === 0) { return (

No article series found

); } return (
{enriched.map((serie) => ( ))}
); } ``` We need a new helper, `src/components/Blog/utils/series.ts`: **src/components/Blog/utils/series.ts** ```typescript import { translate } from "@docusaurus/Translate"; import { createSlug } from "@site/src/components/Blog/utils/slug"; import { useSeriesLocalizer } from "@site/src/components/Blog/utils/seriesI18n"; import { useBlogMetadata, type BlogPostMetadata } from "@site/src/components/Blog/utils/posts"; /** * series.ts * * Utility function to generate structured metadata for blog article series in Docusaurus. * * It groups blog posts by their `series` field, sorts them chronologically, * and returns a list of series objects containing permalink, image, title, and description. * * @example * const seriesList = generateSeriesList("/series/", "/img/default.webp"); * * Each object in the returned array looks like: * { * seriesName: "Introduction to Docusaurus", * permalink: "/series/introduction-to-docusaurus", * image: "/img/docusaurus-intro.png", * title: "Introduction to Docusaurus", * counter: "5 published article(s) • 2 in progress" * } */ export interface SeriesListEntry { seriesName: string; permalink: string; image: string; /** Displayed series name — localized when the current locale has a row for it. */ title: string; /** * Displayed series description, localized. Absent on the default locale, where the English * `src/data/series.js` is the source of truth and callers read it directly. */ description?: string; /** Human-readable count, e.g. "5 published article(s) • 2 in progress". */ counter: string; } export function useSeriesList( permalink = "/series/", defaultImage = "/img/default.webp", ): SeriesListEntry[] { const seriesMap = new Map(); // Array of blog post objects; drafts are needed here to compute the "in progress" counter const posts = useBlogMetadata({ includeDrafts: true }); const localizeSeries = useSeriesLocalizer(); // Loop all posts, process the ones that are part of a serie and push the post entry in his own series posts.forEach((post) => { const seriesName = post.series; if (seriesName) { if (!seriesMap.has(seriesName)) { seriesMap.set(seriesName, []); } seriesMap.get(seriesName)!.push(post); } }); // Process all series, process all posts and generate an array with the name of the serie, a link // to a page where we can access the articles, ... (see the @example in the intro docblock) return [...seriesMap.keys()].sort().map((seriesName) => { const posts = seriesMap.get(seriesName)!; const sortedPosts = [...posts].sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), ); const publishedCount = posts.filter((post) => !post.draft).length; const draftCount = posts.filter((post) => post.draft).length; const counter = translate( { id: "blog.series.publishedCount", message: "{count} published article(s)" }, { count: publishedCount }, ) + (draftCount > 0 ? ` • ${translate( { id: "blog.series.inProgress", message: "{count} in progress" }, { count: draftCount }, )}` : ""); // `seriesName` stays the ENGLISH name — it is the key that drives the slug and links the // article front matter. Only what is displayed is localized. See utils/seriesI18n.ts. const localized = localizeSeries(seriesName); return { seriesName, permalink: `${permalink}${createSlug(seriesName)}`, image: sortedPosts[0]?.image || defaultImage, title: localized?.label ?? seriesName, description: localized?.description, counter, }; }); } ``` In short, the `SeriesCards` component is quite simple. It will just retrieve all series from your existing blog posts and then display them as cards. Nothing more. ### 2.3 Creation of the PostCard component We also need a component for displaying a post as a card. Please create the `src/components/Blog/PostCard/index.tsx` file with this content: **src/components/Blog/PostCard/index.tsx** ```typescript import type { JSX } from "react"; import Card from "@site/src/components/Card"; import CardBody from "@site/src/components/Card/CardBody"; import CardImage from "@site/src/components/Card/CardImage"; import Link from "@docusaurus/Link"; import Translate, { translate } from "@docusaurus/Translate"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import useBaseUrl from "@docusaurus/useBaseUrl"; import { formatPostDate } from "@site/src/components/Blog/utils/date"; import { createSlug } from "@site/src/components/Blog/utils/slug"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import styles from "./styles.module.css"; interface Post { permalink: string; image?: string; title: string; // `null` is accepted alongside `undefined`: LatestPosts / RelatedPosts pass // `{ ...post, description: null }` to render a card without its description, // and getBlogMetadata() types `mainTag` as `string | null`. description?: string | null; date?: string; counter?: string; mainTag?: string | null; readingTime?: number; } interface Props { post: Post; layout?: "big" | "small"; defaultImage?: string; lazyImage?: boolean; } export default function PostCard({ post, layout = "big", defaultImage = "/img/default.jpg", lazyImage = true, }: Props): JSX.Element { const { permalink, image, title, description, date, counter, mainTag, readingTime } = post; const { i18n } = useDocusaurusContext(); const formattedDate = formatPostDate(date, i18n.currentLocale); const tagLabel = useTagLabel(); // `image` is a front-matter path (`/img/v2/atuin.webp`) — a string this repo wrote, not one // Docusaurus handed us, so it does NOT carry the locale's baseUrl. Under `fr` the static file // is served from `/fr/img/...`; requesting `/img/...` gets the dev server's SPA fallback, // which answers `200 text/html` and paints a broken banner rather than 404-ing visibly. // Only the "small" layout needs this: the "big" one goes through ``, which calls // `useBaseUrl` itself. const smallLayoutImageUrl = useBaseUrl(image || defaultImage); if (layout === "small") { return (
{title}

{title}

{counter &&
{counter}
} {formattedDate &&

{formattedDate}

}
Read more
); } // Default "big" layout return (
{mainTag && ( {tagLabel(mainTag)} )}

{title} →

{description &&

{description}

} {counter &&

{counter}

} {(formattedDate || readingTime) && (

{formattedDate && {formattedDate}} {readingTime && ( {" "} ·{" "} {"{minutes} min read"} )}

)}
); } ``` Create `src/components/Blog/PostCard/styles.module.css` as well: **src/components/Blog/PostCard/styles.module.css** ```css /* * PostCard. * * Note the removal of the :root block at the top of the old file: it declared * --card-radius / --card-transition / --card-hover-transform from inside a CSS * module, and Blog/Updated consumed --card-radius through a fallback. Two * unrelated components were coupled through a variable whose definition * depended on PostCard being on the page. Those now live in theme.css. * * The tag badge was a filled terracotta pill — the loudest thing in a grid of * twenty cards. It becomes the shared .mk-pill: cream ground, terracotta text. */ .cardEnhanced { composes: mk-surface from global; composes: mk-liftable from global; display: flex; flex-direction: column; height: 100%; min-width: 0; overflow: hidden; } .cardBodyEnhanced { display: flex; flex: 1; flex-direction: column; gap: 0.5rem; padding: 1rem; } .cardImageEnhanced { display: block; width: 100%; aspect-ratio: 2 / 1; object-fit: contain; background: var(--mk-n-100); transition: transform 0.2s ease; } .cardEnhanced:hover .cardImageEnhanced { transform: scale(1.03); } .cardTitle { display: -webkit-box; margin: 0; overflow: hidden; font-family: var(--mk-font-display); font-size: 1.02rem; font-weight: 700; line-height: 1.35; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .cardTitleLink { color: var(--mk-ink); text-decoration: none; transition: color 0.15s ease; } .cardTitleLink:hover { color: var(--mk-primary); text-decoration: none; } .cardTagBadge { composes: mk-pill from global; align-self: flex-start; } /* * The date was italic and the reading time italic too — two italics in a * four-word line. Both are now plain, quiet, and tabular so the dates line up * from card to card in a grid. */ .date { margin-bottom: 0; font-size: 0.82rem; color: var(--mk-ink-soft); font-variant-numeric: tabular-nums; } .readingTime { font-variant-numeric: tabular-nums; } .description { display: -webkit-box; margin: 0; overflow: hidden; font-size: 0.88rem; line-height: 1.55; color: var(--mk-ink-soft); -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .counter { composes: mk-pill from global; margin-top: 0.5rem; } /* ── Small layout ─────────────────────────────────────── */ .cardSmallWrapper { display: flex; margin-bottom: 2rem; } .cardSmall { display: flex; flex-direction: column; width: 100%; height: 100%; } .cardSmallImage { width: 100%; aspect-ratio: 2 / 1; object-fit: contain; background: var(--mk-n-100); border-radius: var(--mk-radius-lg) var(--mk-radius-lg) 0 0; } .cardSmallBody { flex: 1; } .cardSmallCounter { composes: mk-eyebrow from global; margin-bottom: 0.375rem; color: var(--mk-primary); } .cardSmallFooter { text-align: right; } ``` The `PostCard` component is using the [Reusable Card component of Docux](/blog/docusaurus-cards#using-the-reusable-card-component-of-docux). If you do not have it yet, please create at least these four files: **src/components/Card/index.tsx** ```typescript import type { CSSProperties, JSX, ReactNode } from "react"; import clsx from "clsx"; // clsx helps manage conditional className names in a clean and concise manner. interface Props { /** Custom classes for the container card */ className?: string; /** Custom styles for the container card */ style?: CSSProperties; /** Content to be included within the card */ children?: ReactNode; /** Shadow under the card. Expected values: low (lw), medium (md), tall (tl) */ shadow?: "lw" | "md" | "tl"; } const Card = ({ className, style, children, shadow }: Props): JSX.Element => { const cardShadow = shadow ? `item shadow--${shadow}` : ""; return (
{children}
); }; export default Card; ``` **src/components/Card/styles.module.css** ```css .truncate p { display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0; overflow-wrap: break-word; max-height: 4.5em; /* 3 lines × 1.5em line-height */ line-height: 1.5em; } ``` **src/components/Card/CardBody/index.tsx** ```typescript import type { CSSProperties, JSX, ReactNode } from "react"; import clsx from "clsx"; import styles from "../styles.module.css"; import { buildTextClasses, type CardTextOptions } from "../utils"; interface Props extends CardTextOptions { /** className for the container card */ className?: string; /** Custom styles for the container card */ style?: CSSProperties; /** Content to be included within the card */ children?: ReactNode; } const CardBody = ({ className, style, children, textAlign, variant, italic = false, noDecoration = false, transform, truncate = false, weight, }: Props): JSX.Element => { const { text, textColor, textItalic, textDecoration, textType, textTruncate, textWeight, } = buildTextClasses( { textAlign, variant, italic, noDecoration, transform, truncate, weight }, styles.truncate, ); return (
{children}
); }; export default CardBody; ``` **src/components/Card/CardImage/index.tsx** ```typescript /** * CardImage Component * * Renders an image for a card layout with optional lazy loading and * customizable size. Automatically resolves the image URL using Docusaurus's * `useBaseUrl` hook. Applies default styling via the "card__image" class * and supports additional custom styles. * * Notes: * - Using `width` and `height` helps reduce layout shift by reserving space * for the image before it loads. * - You can also override sizing via CSS or the `style` prop if more flexibility is needed. */ import type { CSSProperties, JSX } from "react"; import clsx from "clsx"; import useBaseUrl from "@docusaurus/useBaseUrl"; interface Props { /** Optional additional CSS classes to apply. */ className?: string; /** Inline styles for the image element. */ style?: CSSProperties; /** Relative or absolute path to the image. */ cardImageUrl: string; /** Alternative text for accessibility. */ alt: string; /** Optional tooltip text shown on hover. */ title?: string; /** If true, enables `loading="lazy"` to defer image loading until visible. */ lazy?: boolean; /** Optional width of the image in pixels. */ width?: number; /** Optional height of the image in pixels. */ height?: number; } const CardImage = ({ className, style, cardImageUrl, alt, title, lazy = true, width, height, }: Props): JSX.Element => { const generatedCardImageUrl = useBaseUrl(cardImageUrl); return ( {alt} ); }; export default CardImage; ``` ### Testing our /series URL Everything is now in place for our `/series` web page. We have created `src/pages/series.mdx` and all its dependencies. Now, simply access the `/series` URL. On my localhost, it's `http://127.0.0.1:3000/series`. You should get the same page already teased at the top of this article, without any styling yet. Great, right? ### 2.4 Adding an entry for the series page to your blog Edit your `docusaurus.config.js` file, and in the `navbar` -> `items` section, please add a link to `/series`: **docusaurus.config.js** ```javascript // [...] const config = { themeConfig: ({ navbar: { items: [ // [...] { href: "/series", label: "My series", }, // [...] ], }, }), }; export default config; ``` ![My series](./images/my-series.webp) ### 2.5 Handling the /series/slug URL Okay, it works, but from the `/series` page, if you click on a series, you will get this error: ![Page 404 when visiting a specific series](./images/page-404.webp) So, we have just created the `/series` page. But when your reader clicks on a series to get the list of articles, the URL will become something like `/series/the-name-of-the-series`. Thus, we need to tell Docusaurus how to handle these new URLs. By default, Docusaurus does not know what to do. For this, we will need a plugin. Please create the `plugins/docusaurus-plugin-series-route/index.cjs` file with this content: **plugins/docusaurus-plugin-series-route/index.cjs** ```cjs /** * Docusaurus Plugin: series-route * * Purpose: * This plugin adds the routes for displaying articles by series slug under the * `/series/` URL pattern. * * Why: * Docusaurus by default does not support routes like `/series/`. This plugin * programmatically registers one route per series, each pointing to the * `SeriesArticlesPage` React component, which fetches and renders articles based on * the series slug taken from the URL. * * Why one route per series instead of a single `/series/:slug` (TODO 0092): * Docusaurus's `handleBrokenLinks` resolves links against the list of *registered* * routes, and a parameterized path matches nothing there. With a single `:slug` route * every `` was reported as broken, * which is why `onBrokenLinks` had to be set to "ignore" site-wide — and a genuinely * dead link then went out silently with it. Enumerating the real series at build time * makes those links resolvable, so link checking can be switched back on. * * Usage: * - Save this plugin as `plugins/docusaurus-plugin-series-route/index.cjs` * - Add the plugin to `docusaurus.config.js` plugins array. * - Create the React component at the specified path (`src/components/Blog/Series/SeriesArticlesPage.js`). * * This allows URLs like `/series/my-series-name` to display articles for that series. * * See readme.md for more details */ const path = require("path"); const { normalizeUrl } = require("@docusaurus/utils"); const { listSeriesSlugs } = require("../lib/blog-taxonomy.cjs"); module.exports = function (context) { return { name: "docusaurus-plugin-series-route", // Routes are enumerated from article frontmatter, so a new tag/series would // otherwise only get a route on the next dev-server restart. Watching the corpus // makes `yarn start` pick it up on save, like the parameterized route used to. getPathsToWatch() { return [path.join(context.siteDir, "blog", "**", "index.{md,mdx}")]; }, async contentLoaded({ actions }) { for (const slug of listSeriesSlugs(context.siteDir)) { actions.addRoute({ // Through `baseUrl` — see the same comment in docusaurus-plugin-tag-route: a // hardcoded absolute path registers a route no locale-prefixed link can reach. path: normalizeUrl([context.baseUrl, "series", slug]), component: "@site/src/components/Blog/Series/SeriesArticlesPage", exact: true, }); } }, }; }; ``` As you can see in the plugin's source code, we need a new component: `src/components/Blog/Series/SeriesArticlesPage.tsx`. Let's create it: **src/components/Blog/Series/SeriesArticlesPage.tsx** ```typescript import type { CSSProperties, JSX } from "react"; import { createSlug } from "@site/src/components/Blog/utils/slug"; import { useBlogMetadata } from "@site/src/components/Blog/utils/posts"; import { hexToRgba } from "@site/src/components/Blog/utils/color"; import { useSeriesLocalizer } from "@site/src/components/Blog/utils/seriesI18n"; import { useLocation, matchPath } from "@docusaurus/router"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import { useSourceLocaleUrls } from "@site/src/components/Blog/utils/localeUrls"; import Layout from "@theme/Layout"; import Link from "@docusaurus/Link"; import Translate, { translate } from "@docusaurus/Translate"; import PostCard from "@site/src/components/Blog/PostCard"; import SERIES_DATA from "@site/src/data/series.js"; import Head from "@docusaurus/Head"; import FollowFeed from "@site/src/components/FollowFeed"; import styles from "./styles.module.css"; const DEFAULT_IMAGE = "/img/default.webp"; export default function SeriesArticlesPage(): JSX.Element { const location = useLocation(); // Hooks must run before any early return. const posts = useBlogMetadata(); const localizeSeries = useSeriesLocalizer(); // The hook flavour would have to sit here, above the early return, but the banner path is only // known further down — so take the plain function and call it where the value exists. const { withBaseUrl } = useBaseUrlUtils(); const { isDefaultLocale, sourceLabel, sourceLocale, sourceUrl } = useSourceLocaleUrls(); // The pattern MUST carry `baseUrl`: under the `fr` locale the pathname is `/fr/series/` // and a hardcoded pattern silently matches nothing, rendering the "not found" branch on a // perfectly valid page (TODO 0119). const { siteConfig } = useDocusaurusContext(); const base = siteConfig.baseUrl.replace(/\/$/, ""); const match = matchPath<{ slug: string }>(location.pathname, { path: `${base}/series/:slug`, exact: true, }); const slug = match?.params?.slug; if (!slug) { return (

No series specified.

Go back to all series
); } const seriesPosts = posts.filter((post) => { if (!post.series) return false; return createSlug(post.series) === slug; }); // seriesPosts is filtered on a truthy `series`, so the first entry's `series` // is always a string here; `?? slug` covers the empty-list case (and satisfies // the `string | null` type without a cast). const originalSeriesName = seriesPosts[0]?.series ?? slug; const seriesData = SERIES_DATA.find((s) => s.name === originalSeriesName); const seriesBannerUrl = withBaseUrl(seriesData?.image ?? DEFAULT_IMAGE); // `originalSeriesName` is the functional key — it drives the slug, the front matter match and // the RSS feed, and must never be translated. Only these two are shown to the reader, and they // fall back to the English SERIES_DATA when the locale has no row for this series. const localized = localizeSeries(originalSeriesName); const displayName = localized?.label ?? originalSeriesName; const displayDescription = localized?.description ?? seriesData?.description; const heroStyle: CSSProperties | undefined = seriesData?.color ? ({ "--series-accent-solid": hexToRgba(seriesData.color, 1), "--series-accent-light": hexToRgba(seriesData.color, 0.16), "--series-accent-dark": hexToRgba(seriesData.color, 0.3), "--series-accent-glow-light": hexToRgba(seriesData.color, 0.35), "--series-accent-glow-dark": hexToRgba(seriesData.color, 0.55), } as CSSProperties) : undefined; const sortedPosts = seriesPosts.sort( (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() ); return (
{sortedPosts.length > 0 && ( <> {/* /series/ is a custom route (docusaurus-plugin-series-route), so nothing injects feed autodiscovery here on its own — see src/components/MarkdownAlternate for the same pattern. */} {/* The same series in the source language: more articles, since only part of the series is translated. Helmet keys on `href`, so this is kept too. */} {!isDefaultLocale && ( )} )} {sortedPosts.length > 0 ? (
{sortedPosts.map((post) => ( ))}
) : (

No articles found for this series

Oops, it looks like that series doesn't exist. Please check the name in the URL to make sure it's correct.

Click here to browse all available series from our homepage.
)}
); } ``` And we need to load this plugin, so we will need to update the `docusaurus.config.js` file again: **docusaurus.config.js** ```javascript // [...] import pluginSeriesRoute from "./plugins/docusaurus-plugin-series-route/index.cjs" const config = { // [...] // WE SHOULD IGNORE BROKEN LINKS because Docusaurus’s link checker doesn't // recognize dynamic routes created via plugins; and we're using at least one // i.e. "plugins/docusaurus-plugin-series-route/index.cjs". // If we don't ignore broken links, Docusaurus will always throw an error during build // time. onBrokenLinks: "ignore", // [...] plugins: [ // [...] [pluginSeriesRoute, {}], ], // [...] }; export default config; ``` > **Danger:** > > The `onBrokenLinks` property has to be set to `ignore` because Docusaurus does not load router plugins while it is rendering the static version of the site. Therefore, it will not understand any `/series/xxx` URLs and will think they are broken. For the last time, please restart your Docusaurus server. Now, in your main menu, you should have your `Series` entry. By clicking on it, you will get the list of series. By clicking on a series, you will get a new page with the list of articles in that series. Just click on an article to jump to it. ![Seeing a specific series](./images/a-series.webp) ## Part 3 - Adding a /src/data/series.js file for customization The component is working fine right now, but we are missing two important features: 1. The ability to write a description for the series. 2. The ability to use a custom image. Let's create a new file called `/src/data/series.js` with content like this: **/src/data/series.js** ```javascript const SERIES_DATA = [ { name: "Bluesky Docusaurus component", description: "Build and integrate Bluesky-powered components inside Docusaurus.", image: "/img/series/bluesky.webp", }, { name: "Coding using a devcontainer", description: "Develop consistently across machines using VS Code Dev Containers.", image: "/img/series/devcontainer.webp", }, { name: "Create your joomla website using Docker", description: "Run Joomla locally using Docker and streamline your web development.", image: "/img/series/joomla.webp", }, { name: "Creating Docusaurus components", description: "Custom components, hooks, layouts, and reusable UI for your Docusaurus site.", image: "/img/series/components.webp", }, // ... ]; export default SERIES_DATA; ``` You have to respect one important rule: the value you specify in the `name` key must match the value of your `series` as specified in your YAML front matter. So, for instance, for this blog post, my YAML key is `series: Creating Docusaurus components`, so I should find it in my `/src/data/series.js`. The component will then be able to find the `description` and the `image` to use. Now, your Series page will look like this: ![The series page with a description](./images/with-description.webp) ## Conclusion It was definitely difficult. There were many files to create, others to update, and a few configuration elements to fine-tune, but wasn't the result worth it? Now you have a blog where you can write 'pillar' articles with content so dense that you can easily break it down into different parts and offer it to your readers as a series of articles. Your readers will be guided through the process, enabling them to better understand the different steps to follow. From an SEO perspective, you will enjoy greater visibility thanks to these 'pillar' articles, among other things, but also because you will improve the structure of your page navigation. Search engines greatly appreciate this. --- # 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 `` 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: ![Close-up of the round scroll-to-top button showing the meerkat icon](./images/go_top_button_zoom.webp) The button stays hidden until you've scrolled past 300 pixels, so a short page never shows it. Two files and one override, below. ## How it works Docux has created a [ScrollToTopButton Docusaurus component](https://github.com/Juniors017/docux-blog/tree/main/src/components/ScrollToTopButton) i.e. a piece of Javascript with css; nothing more. Please create the two files below in your blog directory structure: **src/components/ScrollToTopButton/index.tsx** ```typescript import { useState, useEffect, type JSX } from "react"; import clsx from "clsx"; import { translate } from "@docusaurus/Translate"; import styles from "./styles.module.css"; import buttontop from "@site/static/img/meerkat/suricate_no_background.webp"; export default function ScrollToTopButton(): JSX.Element { const [isVisible, setIsVisible] = useState(false); const [fly, setFly] = useState(false); useEffect(() => { const toggleVisibility = () => setIsVisible(window.scrollY > 300); window.addEventListener("scroll", toggleVisibility, { passive: true }); return () => window.removeEventListener("scroll", toggleVisibility); }, []); const scrollToTop = () => { setFly(true); window.scrollTo({ top: 0, behavior: "smooth" }); setTimeout(() => setFly(false), 800); }; return ( ); } ``` **src/components/ScrollToTopButton/styles.module.css** ```css .scrollBtn { position: fixed; bottom: 30px; right: 30px; width: 60px; height: 60px; cursor: pointer; opacity: 0; visibility: hidden; transition: opacity 0.4s, visibility 0.4s; z-index: 1000; border: none; background: transparent; padding: 0; } .scrollBtn img { width: 100%; height: 100%; object-fit: contain; border-radius: 50%; box-shadow: var(--mk-shadow-3); transition: transform 0.3s; } .scrollBtn:hover img { transform: scale(1.1); } /* Bouton visible après scroll */ .scrollBtn.show { opacity: 1; visibility: visible; } /* Animation "vol vers le haut" */ .scrollBtn.fly img { animation: flyUp 0.8s ease-in forwards; } @keyframes flyUp { 0% { transform: translateY(0) scale(1); opacity: 1; } 100% { transform: translateY(-300px) scale(0.6); opacity: 0; } } /* The meerkat flying off the top of the page is the site's signature gesture, so it is kept — but it is pure decoration, and a reader who asked for less motion should get the plain jump. */ @media (prefers-reduced-motion: reduce) { .scrollBtn.fly img { animation: none; } .scrollBtn:hover img, .scrollBtn img { transition: none; transform: none; } } @media (max-width: 600px) { .scrollBtn { width: 44px; height: 44px; bottom: 16px; right: 16px; } } ``` Then, you need to inject `` in your pages. *This injection is done by swizzling `BlogPostItem`, exactly as in [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts) and [Adding Reader Reactions to Your Docusaurus Blog](/blog/docusaurus-reactions); if you already did one of those, you have the file.* > **Information:** > > The component imports its icon on the very first lines: `import buttontop from "@site/static/img/meerkat/suricate_no_background.webp"`. Point that import at your own square image in `/static` and you're done. ## Overriding the BlogPostItem page Please follow the "[We need to override how the article is rendered by Docusaurus](/blog/docusaurus-bluesky-share/#we-need-to-override-how-the-article-is-rendered-by-docusaurus)" chapter of a previous post. You need to create the `src/theme/BlogPostItem/index.js` file. If you don't want the long story; don't read that chapter and just create this file: **src/theme/BlogPostItem/index.js** ```javascript import React from 'react'; import clsx from 'clsx'; import {useBlogPost} from '@docusaurus/plugin-content-blog/client'; import BlogPostItemContainer from '@theme/BlogPostItem/Container'; import BlogPostItemHeader from '@theme/BlogPostItem/Header'; import BlogPostItemContent from '@theme/BlogPostItem/Content'; import BlogPostItemFooter from '@theme/BlogPostItem/Footer'; // apply a bottom margin in list view function useContainerClassName() { const {isBlogPostPage} = useBlogPost(); return !isBlogPostPage ? 'margin-bottom--xl' : undefined; } export default function BlogPostItem({children, className}) { const containerClassName = useContainerClassName(); return ( {children} ); } ``` Once you have it, please edit it since we need to inject the `` code. Below, see the two highlighted lines you should add: **src/theme/BlogPostItem/index.js** ```javascript import React from 'react'; import clsx from 'clsx'; import {useBlogPost} from '@docusaurus/plugin-content-blog/client'; import BlogPostItemContainer from '@theme/BlogPostItem/Container'; import BlogPostItemHeader from '@theme/BlogPostItem/Header'; import BlogPostItemContent from '@theme/BlogPostItem/Content'; import BlogPostItemFooter from '@theme/BlogPostItem/Footer'; // highlight-next-line import ScrollToTopButton from "@site/src/components/ScrollToTopButton"; // apply a bottom margin in list view function useContainerClassName() { const {isBlogPostPage} = useBlogPost(); return !isBlogPostPage ? 'margin-bottom--xl' : undefined; } export default function BlogPostItem({children, className}) { const containerClassName = useContainerClassName(); return ( {children} // highlight-next-line ); } ``` > **We need to restart Docusaurus:** > > Now, because we've just introduced an override, we need to restart our Docusaurus server so changes can be taken into account. > **Run npm run start:** > > If you're running Docusaurus locally, just run `npm run start` in your console. > If like me you're running Docusaurus thanks to Docker, just kill the container and run a new one. ## You can do this for other pages for sure On my site, I have an `archive` page. You want to use the `` button there too? Just swizzle the page by running `yarn docusaurus swizzle @docusaurus/theme-classic BlogArchivePage` in a console then update the `index.js` file as illustrated above. If you have any other pages in your `src/pages/` folder (like `about.mdx`, `index.mdx`, ...), no need to swizzle first, just directly add the two lines in these files too. ## Conclusion No more dislocated thumbs: one small component, one swizzled `BlogPostItem`, and every long article on your site gets a way back to the top. The fun part is that the icon is yours; mine waves at readers who scroll too far. If you're building up your set of Docusaurus components, the next one worth adding is [related posts below each article](/blog/docusaurus-relatedposts), which reuses the exact same override file. --- # Creating a search&replace plugin for Docusaurus > Automatically replace “markdown” with “Markdown” across all your content to keep your posts consistent. --- ![Creating a search\&replace plugin for Docusaurus](/img/v2/docusaurus_component.webp) **TL;DR** This article demonstrates how to create a custom Docusaurus remark plugin for automatically finding and replacing specific terms across your entire site. The goal is to ensure consistent terminology (e.g., changing 'vscode' to 'VSCode') without modifying the original Markdown files. You'll learn how to write a plugin that traverses the content's abstract syntax tree (AST) to safely apply replacements only to plain text, avoiding code blocks and URLs. The guide also shows how to configure the plugin in your `docusaurus.config.js`. For fun (because that solution is perhaps not bullet proof), I asked an AI to generate a plugin to scan my 200 articles and replace patterns like `docusaurus` by `Docusaurus`, `github` by `GitHub`, `vscode` by `VSCode`, to normalize them across all my content. It can be risky because if the word `vscode` appears in: * a URL (like `https://github.com/microsoft/vscode/`), * a name (like `vstirbu.vscode-mermaid-preview`), * as a file name (like `vscode.png`), * a code snippet (inside a ` ```...``` ` or `` `.` `` block), * ... we certainly do not want to make the replacement. But, if the word is inside a simple paragraph, that's a different story — we do want the replacement there. So, after a few prompts with the AI, a plugin has been generated and it works so far. Restart your Docusaurus server once the plugin is in place and, on the next start, if some changes have to be made, you'll see them in your console: ```bash 🔎 Replacing 'vscode' with 'VSCode' in file: /opt/[...]/index.md Sentence: One of the best features in vscode is the 🔎 Replacing 'vscode' with 'VSCode' in file: /opt/[...]/index.md Sentence: With vscode, it's ultra-simple: multiple cursors. ``` Silent, safe, and it never touches your original Markdown files — only the rendered HTML. Let's build it. ## The plugin Please create the `plugins/remark-replace-terms/index.cjs` file and look at the `replacements` array. Please add yours. *This is a **remark** plugin: it walks the Markdown AST before the HTML is produced. I've used the same mechanism to [replace every `` tag with my own component](/blog/docusaurus-override-img) and to [inject AI-generated tooltips into code snippets](/blog/docusaurus-eli5-snippet-tooltips).* The syntax is `[/\b(1)\b/g, "(2)"],` where `(1)` is the word to search for (exactly written as is (case sensitive)) and `(2)` the replaced by value. **plugins/remark-replace-terms/index.cjs** ```cjs /** * @fileoverview * Docusaurus Remark Plugin – remark-replace-words * * This plugin is designed for use with Docusaurus to automatically correct the casing * of common technology terms in Markdown content. It scans Markdown AST text nodes and * replaces lowercase variants like "github" or "markdown" with their properly capitalized * forms ("GitHub", "Markdown", etc.). * * ⚠️ Replacements are skipped inside links, images, and code blocks to avoid unintended changes. * 🧠 Compound words (e.g., "vscode-docker") are also preserved. * * Intended for use in the Docusaurus Markdown pipeline via `remarkPlugins`. * * @module remarkReplaceWords * @returns {Function} A remark transformer function */ const { visit } = require("unist-util-visit"); // Define replacements as [searchRegex, replacement] const replacements = [ [/\bdocusaurus\b/g, "Docusaurus"], [/\bgithub\b/g, "GitHub"], [/\bmarkdown\b/g, "Markdown"], [/\bvscode\b/g, "VSCode"], ]; // A heading's explicit anchor id — `## Docusaurus {#docusaurus}` — is still ordinary text when // this plugin runs: it sits in `beforeDefaultRemarkPlugins`, so Docusaurus parses that `{#id}` // suffix only afterwards. Capitalizing a term inside it rewrites the id itself (`{#Docusaurus}`), // and every link built from the English slug then points at an anchor the page no longer has — // which is exactly how the `fr` build died on "broken anchors". Ids are never prose: skip them. // Translated articles carry one on every heading (see scripts/lib/translate-anchors.mjs). const EXPLICIT_HEADING_ID = /\{#[^}\s]*\}/g; /** Applies `transform` to `value`, leaving every explicit `{#heading-id}` untouched. */ function outsideHeadingIds(value, transform) { const parts = []; let cursor = 0; for (const match of value.matchAll(EXPLICIT_HEADING_ID)) { parts.push(transform(value.slice(cursor, match.index)), match[0]); cursor = match.index + match[0].length; } parts.push(transform(value.slice(cursor))); return parts.join(""); } function remarkReplaceWords() { return (tree) => { visit(tree, "text", (node, index, parent) => { if (typeof node.value !== "string") return; // Skip inside certain node types (URLs, code, etc.) const forbiddenParents = ["link", "image", "inlineCode", "code"]; if (parent && forbiddenParents.includes(parent.type)) { return; } node.value = outsideHeadingIds(node.value, (text) => { let result = text; for (const [regex, replacement] of replacements) { result = result.replace(regex, (match, offset, fullString) => { const before = fullString[offset - 1] || ""; const after = fullString[offset + match.length] || ""; // Skip compound words like "vscode-docker", "foo.markdown" if (["-", ".", "/"].includes(before) || ["-", ".", "/"].includes(after)) { return match; } return replacement; }); } return result; }); }); }; } module.exports = remarkReplaceWords; ``` ## Adding the plugin in your configuration The next thing to do is to register your plugin into your Docusaurus configuration. To do this, edit your `docusaurus.config.js` file and add the highlighted lines as illustrated below. **docusaurus.config.js** ```javascript // highlight-next-line import remarkReplaceWords from "./plugins/remark-replace-terms"; const config = { // [ ... ] presets: [ [ 'classic', ({ // [ ... ] blog: { beforeDefaultRemarkPlugins: [ // [ ... ] // highlight-next-line remarkReplaceWords ], }, // [ ... ] }), ], ], }; export default config; ``` This done, restart your Docusaurus server and you'll get the same console output already shown at the top of this article. > **Caution:** > > The search\&replace action won't be done on your original Markdown files but only during the HTML rendering. It's thus safe to run this plugin; your files won't be impacted at all. --- # A component for showing code snippets in a Docusaurus blog > Learn how to build a custom React component to embed dynamic code snippets in your Docusaurus blog for a more interactive reader experience. --- ![A component for showing code snippets in a Docusaurus blog](/img/v2/docusaurus_react.webp) **TL;DR** This article guides you through creating a powerful `` component for Docusaurus to display code blocks. This custom component improves on native HTML by adding features like file-type-specific icons and custom styling. The tutorial shows how to build the React component, integrate it with an icon library, and then takes it a step further by creating a remark plugin. This plugin allows you to dynamically load code from source files directly into your articles, ensuring your snippets are always up-to-date without manual copy-pasting. You'll learn how to handle both relative and absolute file paths and register the plugin in your site's configuration. If you're a regular reader of this blog, you know I'm sharing a lot of code snippets. *This component became the foundation of two others: [ProjectSetup](/blog/docusaurus-project-setup), which assembles several snippets into a downloadable project tree, and [the ELI5 tooltips](/blog/docusaurus-eli5-snippet-tooltips), which annotate the tricky lines of a snippet.* The HTML native way of doing this is by using the `` element (see [official documentation](https://www.w3schools.com/tags/tag_summary.asp)). Can we do something, perhaps not better but more esthetic? ## The summary element In pure HTML, we can use the `` DOM element like this: ```html
blog/index.md Hello world! Proud to be here!!!
``` > **Information:** > > It works but ... can we do better. I think to f.i. add an icon based on the language type, also do color's distinction between language and much, much better, don't copy/paste the code anymore in the article but just read it from the disk. In this article we'll learn how to create a `Snippets` component and get this look\&feel: ```html Hello world! Proud to be here!!! ``` and here is the rendering: **blog/index.md** ```markdown Hello world! Proud to be here!!! ``` ## Creation of our Snippets component Please create the `src/components/Snippet/index.tsx` file with this content: **src/components/Snippet/index.tsx** ```typescript import React, { useState, useRef, useEffect, useCallback, useMemo, useId, type CSSProperties, type JSX, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import Prism from "prismjs"; import { useTokenize } from "prism-react-renderer"; // Eli5CodeBlock (below) needs its own Prism grammars registered on this exact // `prismjs` singleton — everything beyond markup/html/xml/css/clike/javascript // (Prism's default bundle) only exists here as a side effect of requiring its // component file. `prism-react-renderer` (peer-depends on "prismjs": "*", so // it shares this same module instance) registers `additionalLanguages` the // same way whenever a native `` renders — meaning `Prism.languages // .bash` etc. was previously only defined here if some *other* CodeBlock had // already rendered first in this process/session. Registering every grammar // `Eli5CodeBlock` can receive (see remark-snippet-loader's `extensionToLang` // map) unconditionally, here, makes that no longer order-dependent. // markup/html/xml/css/javascript need no entry — already in Prism's default // bundle (verified empirically). import "prismjs/components/prism-jsx"; import "prismjs/components/prism-typescript"; import "prismjs/components/prism-python"; import "prismjs/components/prism-markup-templating"; // prism-php's own dependency import "prismjs/components/prism-php"; import "prismjs/components/prism-bash"; import "prismjs/components/prism-json"; import "prismjs/components/prism-yaml"; import "prismjs/components/prism-markdown"; import "prismjs/components/prism-docker"; import "prismjs/components/prism-ini"; import "prismjs/components/prism-sql"; import CodeBlock from "@theme/CodeBlock"; import IconCopy from "@theme/Icon/Copy"; import IconSuccess from "@theme/Icon/Success"; import LogoIcon from "@site/src/components/Blog/LogoIcon"; import clsx from "clsx"; import { useVarResolver } from "@site/src/components/Vars/store"; import { substitutePlainText, substituteChildren, } from "@site/src/components/Vars/substitute"; import VarToken from "@site/src/components/Vars/VarToken"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; // Extract language from inside children const getLanguageFromChildren = (children: ReactNode): string | null => { const findLang = (node: ReactNode): string | null => { if (React.isValidElement(node)) { const props = node.props as { className?: unknown; children?: ReactNode; }; if (typeof props.className === "string") { const match = props.className.match(/language-(\w+)/); if (match) return match[1].toLowerCase(); } if (props.children) { const nested = props.children; if (Array.isArray(nested)) { for (const child of nested) { const lang = findLang(child); if (lang) return lang; } } else { return findLang(nested); } } } return null; }; return findLang(children); }; // Maps code language to styling variant const mapLangToVariant: Record = { apacheconf: "apacheconf", asm: "asm", bas: "vb", bash: "bash", bashrc: "bash", bat: "batch", batch: "batch", cjs: "js", cmd: "batch", css: "css", csv: "csv", diff: "diff", docker: "docker", gherkin: "gherkin", html: "html", ignore: "ignore", ini: "ini", java: "java", javascript: "js", js: "js", jsx: "js", json: "json", log: "log", makefile: "makefile", markdown: "md", markdownlint_ignore: "json", md: "md", mdx: "md", mjs: "js", none: "none", pascal: "pascal", php: "php", plaintext: "txt", powershell: "powershell", ps1: "powershell", py: "python", python: "python", qmd: "md", sh: "bash", sql: "sql", svg: "svg", toml: "toml", ts: "ts", tsx: "ts", txt: "txt", typescript: "ts", vb: "vb", vba: "vb", vbnet: "vbnet", vbs: "vb", xml: "xml", yaml: "yaml", yml: "yaml", zsh: "bash", }; // Map variant keys to their icon components and CSS classes // For retrieving the value for the iconify key, just go to "https://icon-sets.iconify.design/?query=python" // and, in the top right search area, type the name of the language. // When found, click on the desired icon and retrieve the "icon name" as displayed in the bottom part. interface VariantIcon { iconClassName: string; iconify: string; ariaLabel: string; } const variantIcons: Record = { apacheconf: { iconClassName: styles.apacheconf_icon, iconify: "logos:apache", ariaLabel: "ApacheConf Logo", }, asm: { iconClassName: styles.asm_icon, iconify: "vscode-icons:file-type-assembly", ariaLabel: "Asm Logo", }, bash: { iconClassName: styles.bash_icon, iconify: "devicon:bash", ariaLabel: "Bash Logo", }, batch: { iconClassName: styles.batch_icon, iconify: "file-icons:ms-dos", ariaLabel: "Batch Logo", }, css: { iconClassName: styles.css_icon, iconify: "ph:file-css", ariaLabel: "CSS Logo", }, csv: { iconClassName: styles.csv_icon, iconify: "ph:file-csv", ariaLabel: "CSV Logo", }, diff: { iconClassName: styles.diff_icon, iconify: "ph:git-diff", ariaLabel: "Diff Logo", }, docker: { iconClassName: styles.docker_icon, iconify: "uil:docker", ariaLabel: "Docker Logo", }, docusaurus: { iconClassName: styles.docker_docusaurus, iconify: "logos:docusaurus", ariaLabel: "Docusaurus Logo", }, gherkin: { iconClassName: styles.gherkin_icon, iconify: "skill-icons:gherkin-light", ariaLabel: "Gherkin Logo", }, html: { iconClassName: styles.html_icon, iconify: "ph:file-html", ariaLabel: "HTML Logo", }, ignore: { iconClassName: styles.ignore_icon, iconify: "codicon:sync-ignored", ariaLabel: "Ignore Logo", }, ini: { iconClassName: styles.ini_icon, iconify: "ph:file-ini", ariaLabel: "INI Logo", }, java: { iconClassName: styles.java_icon, iconify: "vscode-icons:file-type-java", ariaLabel: "Java Logo", }, js: { iconClassName: styles.js_icon, iconify: "logos:javascript", ariaLabel: "JS Logo", }, json: { iconClassName: styles.json_icon, iconify: "ix:json-document", ariaLabel: "JSON Logo", }, log: { iconClassName: styles.log_icon, iconify: "ix:log", ariaLabel: "Log Logo", }, makefile: { iconClassName: styles.makefile_icon, iconify: "vscode-icons:file-type-makefile", ariaLabel: "GNU Makefile Logo", }, md: { iconClassName: styles.md_icon, iconify: "ph:markdown-logo", ariaLabel: "Markdown Logo", }, none: { iconClassName: styles.none_icon, iconify: "ph:empty", ariaLabel: "None Logo", }, pascal: { iconClassName: styles.pascal_icon, iconify: "file-icons:pascal", ariaLabel: "Pascal Logo", }, php: { iconClassName: styles.php_icon, iconify: "bi:filetype-php", ariaLabel: "PHP Logo", }, powershell: { iconClassName: styles.powershell_icon, iconify: "file-icons:powershell", ariaLabel: "Powershell Logo", }, python: { iconClassName: styles.python_icon, iconify: "devicon:python", ariaLabel: "Python Logo", }, sql: { iconClassName: styles.sql_icon, iconify: "ph:file-sql", ariaLabel: "SQL Logo", }, svg: { iconClassName: styles.svg_icon, iconify: "ph:file-svg", ariaLabel: "SVG Logo", }, toml: { iconClassName: styles.toml_icon, iconify: "tabler:toml", ariaLabel: "Toml Logo", }, ts: { iconClassName: styles.ts_icon, iconify: "logos:typescript-icon", ariaLabel: "TypeScript Logo", }, txt: { iconClassName: styles.txt, iconify: "ph:file-txt", ariaLabel: "Txt Logo", }, vb: { iconClassName: styles.vb_icon, iconify: "fluent:document-vb-16-regular", ariaLabel: "VB Logo", }, vbnet: { iconClassName: styles.vbnet_icon, iconify: "fluent:document-vb-16-regular", ariaLabel: "VbNet Logo", }, xml: { iconClassName: styles.xml_icon, iconify: "hugeicons:xml-01", ariaLabel: "XML Logo", }, yaml: { iconClassName: styles.yaml_icon, iconify: "devicon-plain:yaml", ariaLabel: "YAML Logo", }, }; // ELI5 line-by-line renderer — used when eli5json prop is present. // Calls Prism.highlight() directly to get syntax-colored HTML per line, // then overlays interactive ? badges for annotated lines. // The tooltip is rendered via a React Portal into document.body so it // escapes every overflow:hidden / stacking-context ancestor. // C0 control characters other than tab/newline/carriage return, plus DEL. A few example files // carry them for real — `make.bat` colours its output with ESC sequences — and a raw control // character in the page is invalid HTML (the SSG minifier flags it on every build, in every // locale). They are shown as their Unicode "control picture" (ESC -> ␛) instead: visible, honest // about what the file contains, and valid markup. const isControlChar = (code: number): boolean => (code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f; function hasControlChars(text: string): boolean { for (let i = 0; i < text.length; i += 1) { if (isControlChar(text.charCodeAt(i))) return true; } return false; } function showControlChars(text: string): string { if (!hasControlChars(text)) return text; let out = ""; for (let i = 0; i < text.length; i += 1) { const code = text.charCodeAt(i); // U+2400..U+241F picture each C0 control; U+2421 pictures DEL. out += isControlChar(code) ? String.fromCharCode(code === 0x7f ? 0x2421 : 0x2400 + code) : text[i]; } return out; } interface Eli5CodeBlockProps { code: string; lang: string; eli5: Record; } // Eli5CodeBlock renders its own
, bypassing Docusaurus's native
//  (see below) and losing its built-in copy button in the
// process — reimplemented locally rather than pulling in
// `@docusaurus/theme-common/internal`'s CodeBlockContext, which TODO 0112
// already names as a suspect in an open React hydration bug.
function useCopyToClipboard() {
  const [copied, setCopied] = useState(false);
  const timeoutRef = useRef(undefined);

  const copy = useCallback((text: string) => {
    navigator.clipboard.writeText(text).then(() => {
      window.clearTimeout(timeoutRef.current);
      setCopied(true);
      timeoutRef.current = window.setTimeout(() => setCopied(false), 1000);
    });
  }, []);

  useEffect(() => () => window.clearTimeout(timeoutRef.current), []);

  return { copied, copy };
}

function Eli5CodeBlock({ code, lang, eli5 }: Eli5CodeBlockProps): JSX.Element {
  const [activeLine, setActiveLine] = useState(null);
  const [tooltipStyle, setTooltipStyle] = useState(null);
  const { copied, copy } = useCopyToClipboard();
  // false until mounted client-side — gates the tooltip's document.body Portal,
  // which doesn't exist during SSR.
  const [mounted, setMounted] = useState(false);
  const badgeRefs = useRef>({});

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount flag, see comment on `mounted` above
    setMounted(true);
  }, []);

  // `Prism.highlight()` + `.split("\n")` (the previous approach) corrupts any
  // token that spans multiple lines — a `/** … */` JSDoc comment most
  // commonly — because the highlighted HTML gets cut across an unclosed
  // ``. Browsers silently repair that malformed HTML while parsing the
  // page, but repair it *differently* than what React's virtual tree expects
  // once it hydrates, which is a genuine structural mismatch (React error
  // #418) on any snippet whose language has multi-line comments/strings —
  // confirmed via a server-vs-hydrated-DOM diff on `docusaurus-cards.mdx`
  // (see TODO 0112). `useTokenize` avoids the string-splitting step entirely:
  // it walks Prism's real token tree and only ever hands back whole tokens,
  // already split at line boundaries.
  const grammar = useMemo(
    () => Prism.languages[lang] || Prism.languages.plaintext || Prism.languages.clike,
    [lang],
  );
  const tokenizedLines = useTokenize({ prism: Prism, code, grammar, language: lang });
  // A trailing `\n` in `code` makes normalizeTokens() append one synthetic
  // last "line" holding a single `{ content: "\n", empty: true }` token —
  // drop it so line numbers keep matching `eli5`'s 1-based keys, exactly like
  // the previous `rawLines.pop()` did for a trailing blank line.
  const lines = useMemo(() => {
    const last = tokenizedLines[tokenizedLines.length - 1];
    if (last?.length === 1 && last[0].empty) {
      return tokenizedLines.slice(0, -1);
    }
    return tokenizedLines;
  }, [tokenizedLines]);

  // Compute position: tooltip appears above the badge, right-aligned.
  const positionTooltip = useCallback((lineNum: string) => {
    const el = badgeRefs.current[lineNum];
    if (!el) return;
    const rect = el.getBoundingClientRect();
    setTooltipStyle({
      top: rect.top, // translateY(-100%-gap) shifts it above
      right: window.innerWidth - rect.right, // right-align with the badge
    });
  }, []);

  const handleBadgeClick = useCallback(
    (lineNum: string) => setActiveLine((prev) => (prev === lineNum ? null : lineNum)),
    [],
  );

  // Keep the tooltip glued to its badge: position is computed once on
  // hover/focus, but `position: fixed` doesn't move with scrolling — without
  // this, scrolling the page (or the horizontally-scrollable code block)
  // while a tooltip is open leaves it stranded at its old screen position.
  useEffect(() => {
    if (!activeLine) return undefined;
    const reposition = () => positionTooltip(activeLine);
    window.addEventListener("scroll", reposition, { capture: true, passive: true });
    window.addEventListener("resize", reposition, { passive: true });
    return () => {
      window.removeEventListener("scroll", reposition, { capture: true });
      window.removeEventListener("resize", reposition);
    };
  }, [activeLine, positionTooltip]);

  const activeExplanation = activeLine ? eli5[activeLine] : null;

  return (
    <>
      
        
        
          {lines.map((lineTokens, i) => {
            const lineNum = String(i + 1);
            const explanation = eli5[lineNum];
            const isActive = activeLine === lineNum;

            return (
              
{lineTokens.map((token, tokenIdx) => ( {/* Display only: the copy button still copies `code`, real bytes and all. */} {showControlChars(token.content)} ))} {explanation ? ( ) : ( )}
); })}
{mounted && activeExplanation && tooltipStyle && createPortal( {activeExplanation} , document.body, )} ); } // ELI5 verbose summary — a Show/Hide disclosure rendered below the code block, // giving a narrative explanation of the whole snippet (as opposed to // Eli5CodeBlock's per-line tooltips). Kept as its own component, deliberately // outside Eli5CodeBlock's render tree — see TODO 0112 (open React hydration // bug on Eli5CodeBlock/): this feature must not add surface area // there. Also kept as a sibling of the collapsible `snippet_content` div // (not nested inside it): nesting would let this block's own open/close // change the *inner* scrollHeight without the outer collapsible's height // (computed once per its own toggle) ever re-measuring, clipping the summary // whenever a reader opens it after the snippet was already expanded. interface Eli5SummaryBlockProps { summary: string; } function Eli5SummaryBlock({ summary }: Eli5SummaryBlockProps): JSX.Element { const [open, setOpen] = useState(false); const contentRef = useRef(null); const [height, setHeight] = useState("0px"); const contentId = `eli5-summary-${useId()}`; useEffect(() => { if (contentRef.current) { setHeight(open ? `${contentRef.current.scrollHeight}px` : "0px"); } }, [open, summary]); return (

{summary}

); } interface Props { filename?: string; title?: ReactNode; code?: string; children?: ReactNode; /** * Force the open/closed state. Leave undefined and the component decides: * open, unless the snippet is longer than COLLAPSE_OVER_LINES. * * Pass `false` explicitly on file-index sections (ProjectSetup articles, * "All Files at a Glance"): there the page is a LIST of files the reader * scans and opens one of, not a narrative the code belongs to. */ defaultOpen?: boolean; variant?: string; lang?: string; eli5json?: string; eli5summary?: string; } /** * Above this many lines a snippet opens PARTIALLY: the first lines are shown * under a fade, with a "show the remaining N lines" control. Below it, the * snippet simply opens. * * 30 is roughly one screenful of code at 0.85rem/1.6 on a laptop — the point * where "I can take this in at a glance" stops being true. */ const COLLAPSE_OVER_LINES = 30; /** Height of the peek window, in lines. Deliberately less than * COLLAPSE_OVER_LINES so a truncated block always LOOKS truncated. */ const PEEK_LINES = 18; export default function Snippet({ filename, title, code, children, defaultOpen, variant, lang: pluginLang, eli5json, eli5summary, }: Props): JSX.Element { const contentRef = useRef(null); // Parse eli5json string (injected by remark-snippet-loader) into an object const eli5 = useMemo((): Record | null => { if (!eli5json || typeof eli5json !== "string") return null; try { return JSON.parse(eli5json); } catch { return null; } }, [eli5json]); const eli5Summary = typeof eli5summary === "string" && eli5summary.trim() ? eli5summary.trim() : null; const lineCount = useMemo( () => (typeof code === "string" ? code.replace(/\n$/, "").split("\n").length : 0), [code], ); const isLong = lineCount > COLLAPSE_OVER_LINES; /* * The default is OPEN. A reader who clicked "Install Docker under WSL2" came * for the compose file; making them click again to see it is asking them to * work for the thing they searched for. * * Three concrete costs of the old closed-by-default, beyond the extra click: * - find-in-page broke. The collapsed content sits in a max-height: 0 / * overflow: hidden box, which Ctrl+F cannot reach: a reader searching the * page for a variable name got "no results" while the word was right * there. * - printing produced empty boxes. * - the ELI5 "Explain this snippet" control is gated on `open`, so the * line-by-line explanations nobody had expanded were never discovered. * * Long files do not get the old all-or-nothing treatment either: they open * truncated (see PEEK_LINES), so the reader sees WHAT it is immediately and * the page is not eaten by 300 lines. */ const [open, setOpen] = useState(defaultOpen ?? true); const [expandedLong, setExpandedLong] = useState(false); const handleToggle = useCallback(() => setOpen((prev) => !prev), []); const handleExpandLong = useCallback(() => setExpandedLong(true), []); const contentId = `snippet-content-${useId()}`; const lang = useMemo(() => { // 1. Check for the `lang` prop provided by your plugin if (pluginLang) return pluginLang; // 2. Fallback to the existing logic for `children` const languageFromChildren = getLanguageFromChildren(children); if (languageFromChildren) return languageFromChildren; // 3. Fallback to the filename extension if no other language is found if (typeof filename === "string") { const ext = filename.split(".").pop(); return mapLangToVariant[ext ?? ""] || "plaintext"; } return "plaintext"; }, [pluginLang, children, filename]); // Resolve `default` markers (TODO 0088) before rendering. `code` // (from `source=`/`code=`, including files loaded at build time) is plain // text feeding Prism, so substitution there is a string swap — no visible // "this is yours" underline, which the `children` path below gets for // free; an acceptable v1 trade-off, see Vars/readme.md. const resolve = useVarResolver(); const resolvedCode = useMemo( () => (code ? substitutePlainText(code, resolve) : code), [code, resolve], ); const resolvedChildren = useMemo( () => substituteChildren(children, resolve, (name, value, key) => ( {value} )), [children, resolve], ); // Use line-by-line ELI5 renderer when annotations are available and code is a string. // Otherwise fall back to Docusaurus CodeBlock (or raw children). // // Code holding control characters also takes the ELI5 renderer, with no annotations: it is the // only one that can show them escaped while its copy button keeps the original bytes. The // native would either print them raw or copy the escaped version. const codeBlock = useMemo(() => { const hasEli5 = Boolean(eli5 && Object.keys(eli5).length > 0); if (resolvedCode && (hasEli5 || hasControlChars(resolvedCode))) { return ; } if (resolvedCode) { return {resolvedCode}; } return resolvedChildren; }, [resolvedCode, eli5, lang, resolvedChildren]); const baseName = useMemo( () => typeof filename === "string" ? (filename.split("/").pop() ?? "").toLowerCase() : null, [filename], ); const isDockerFile = useMemo(() => { if (!baseName) return false; return ( baseName === "dockerfile" || baseName === "docker" || baseName.endsWith(".docker") || baseName.endsWith(".dockerfile") || baseName.endsWith(".dockerignore") || baseName.endsWith("compose.yaml") || baseName.endsWith("compose.yml") || baseName.endsWith("docker-compose.yaml") ); }, [baseName]); const isDocusaurus = useMemo(() => baseName === "docusaurus.config.js", [baseName]); const variantKey = useMemo( () => variant || (isDockerFile ? "docker" : isDocusaurus ? "docusaurus" : mapLangToVariant[lang]) || "none", [variant, isDockerFile, isDocusaurus, lang], ); const variantClass = styles[`variant_${variantKey}`] || ""; const IconInfo = variantIcons[variantKey] || variantIcons.none; const { iconify, ariaLabel } = IconInfo; const displayTitle = title || filename || (lang ? lang.toUpperCase() : translate({ id: "snippet.defaultTitle", message: "Snippet" })); // Peek mode: long, open, and the reader has not asked for the rest yet. const peeking = open && isLong && !expandedLong; return (
{/* Conditional render, not a max-height animation on a permanently mounted div. With the old approach the collapsed code stayed in the DOM inside an overflow: hidden box — invisible to Ctrl+F but still found by a screen reader, which is the worst of both. Unmounting is honest: closed means closed. */} {open && (
{codeBlock}
{peeking && ( )}
)} {open && eli5Summary && }
); } ``` Also create the stylesheet *(CSS icon and blue border here)*: **src/components/Snippet/styles.module.css** ```css /* * Snippet. * * The language brand colours were NOT regenerated: the 33 values below are * exactly the ones already in this file. What changed is how far each one * reaches. Each variant now declares a single --lang-color, consumed by three * things at once: * * 1. the 3px rule under the toolbar, * 2. the language logo — but only the MONOCHROME ones. Iconify sets * ph:*, bi:*, ix:*, file-icons:* etc. render in currentColor, which is * why they used to come out black; logos:*, devicon:* and * vscode-icons:* ship their own brand colours and ignore it. Both end * up correct. Practical consequence: if a logo ever renders grey, it * is a monochrome icon whose .variant_ class is missing. * 3. the focus ring of the disclosure button. * * So the logo stays, in its real brand colour, and is the only saturated thing * in an otherwise sand-coloured toolbar. Languages present in * mapLangToVariant but with no .variant_ class fall back to the neutral sand * defined on [class^="variant_"] — nothing to add per language. */ /* * IMPORTANT — companion change in index.tsx: * * -
* +
* * The root element was carrying Infima's "alert alert--info" utility classes, * which paint the whole card in Docusaurus blue. Same issue as TLDR and * QuickJump. It was invisible on snippets WITH ELI5 annotations, because * .eli5_pre paints its own background over it; it showed on every other * snippet, where the native is transparent and let the blue * through. * * The .snippet_block rule below is defensive against that class coming back * (Infima sets background/color/border on .alert with plain specificity). */ .snippet_block { composes: mk-surface from global; margin: 1.5rem 0; padding: 0; overflow: hidden; background: var(--mk-surface); color: var(--mk-ink); } /* * ── One surface per rendering path ──────────────────────────────────────── * * A Snippet renders through one of THREE paths, and they had three different * looks: * 1. Eli5CodeBlock — its own
, warm dark
 *   2. native   — Prism's light theme
 *   3. raw children        — rendered MDX prose, no 
 at all, no padding
 *
 * Paths 1 and 2 are code: one dark surface, owned by the 
. Path 3 is
 * prose (a .md snippet renders as text, not as source) and had no padding at
 * all. All three now share ONE dark surface, owned by .snippet_inner: keeping
 * the prose path light was tried and rejected — a .md snippet sitting white
 * between two dark ones read as a different component.
 */
.snippet_inner {
  padding: 0.875rem 1.125rem;
  font-family: var(--ifm-font-family-monospace);
  font-size: 0.85rem;
  line-height: 1.6;
  color: var(--mk-code-fg);
  background: var(--mk-code-bg);
}

.snippet_inner > :last-child {
  margin-bottom: 0;
}

/* :has() — when the snippet holds a real code block, the 
 brings its own
 * padding, so the wrapper gives that up. The surface stays here either way. */
.snippet_inner:has(pre) {
  padding: 0;
}

/* Every engine's output goes transparent; the dark ground above shows through,
 * so all three paths land on exactly one colour. */
.snippet_inner :global(.theme-code-block),
.snippet_inner :global(div[class*="codeBlockContainer"]),
.snippet_inner :global(div[class*="codeBlockContent"]),
.snippet_inner :global(pre[class*="codeBlock"]),
.snippet_inner :global(.prism-code),
.snippet_inner pre,
.snippet_inner pre code {
  margin: 0;
  background: transparent !important;
  border-radius: 0;
  box-shadow: none;
}

/* Prism's light theme also sets a dark text colour — unreadable now. */
.snippet_inner :global(.prism-code),
.snippet_inner pre code {
  color: var(--mk-code-fg);
}

/* Docusaurus's own copy / word-wrap buttons, now on a dark ground. */
.snippet_inner :global(div[class*="buttonGroup"]) button {
  color: var(--mk-n-500);
  background: rgb(255, 255, 255, 0.06);
  border-color: rgb(255, 255, 255, 0.15);
}

.snippet_inner :global(div[class*="buttonGroup"]) button:hover {
  color: var(--mk-code-fg);
  background: rgb(255, 255, 255, 0.12);
}

/* The file header is now the shared toolbar — byte-identical to the one
 * ProjectSetup uses, so the two components read as one family. */
.snippet_summary {
  composes: mk-toolbar from global;
  width: 100%;
  cursor: pointer;
  border: none;
  border-bottom: 1px solid var(--mk-border);
  --chevron-rotation: 0deg;
}

.snippet_summary:hover {
  background: var(--mk-n-100);
}

.snippet_summary[aria-expanded="true"] {
  --chevron-rotation: 180deg;
}

.snippet_summary:focus-visible {
  outline: 2px solid var(--lang-color, var(--mk-primary));
  outline-offset: -2px;
}

/* The filename: an eyebrow, like every other component label. It used to be
 * 1rem bold — the weight of a small heading.
 *
 * .filename_wrapper is the class index.tsx actually uses today; .snippet_filename
 * is the name used in the migration notes. Both are defined so the stylesheet
 * works whether or not the markup is renamed. */
.snippet_filename,
.filename_wrapper {
  composes: mk-eyebrow from global;
  display: flex;
  gap: 0.6rem;
  align-items: center;
  min-width: 0;
  overflow: hidden;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chevron {
  margin-left: auto;
  color: var(--mk-n-500);
  transition: transform 0.3s ease;
  transform: rotate(var(--chevron-rotation));
}

.chevron.rotate {
  transform: rotate(180deg);
}

.snippet_content {
  overflow: hidden;
  transition: max-height 0.35s ease;
}

/*
 * Peek mode: a long snippet opens truncated rather than closed.
 *
 * The height is expressed in LINES, not pixels: --peek-lines is set inline by
 * index.tsx and multiplied by the code block's own line-height here, so the
 * window always cuts between two lines instead of through the middle of one.
 */
.snippet_peek {
  position: relative;
  overflow: hidden;
  max-height: calc(var(--peek-lines, 18) * 1.6 * 0.85rem + 1.75rem);
}

/* The fade tells the reader the block continues. It sits above the content but
   below the button, and must not intercept text selection. */
.snippet_peek::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 5.5rem;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent, var(--mk-code-bg) 85%);
}

.snippet_expand {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 1;
  padding: 0.55rem 1rem;
  font-family: var(--ifm-font-family-base);
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--mk-code-fg);
  cursor: pointer;
  background: none;
  border: none;
}

.snippet_expand:hover {
  color: var(--mk-term-prompt);
}

.snippet_expand:focus-visible {
  outline: 2px solid var(--mk-term-prompt);
  outline-offset: -2px;
}

/* A reader who asked for less motion gets the whole file rather than a
   height that would otherwise animate open. */
@media (prefers-reduced-motion: reduce) {
  .snippet_content {
    transition: none;
  }
}

/*
 * PRINT — the reason default-open matters most. A printed article must carry
 * its code: no truncation window, no fade, no control.
 */
@media print {
  .snippet_peek {
    max-height: none;
  }

  .snippet_peek::after,
  .snippet_expand,
  .chevron {
    display: none;
  }
}

.snippet_toolbar {
  display: flex;
  justify-content: flex-end;
  padding: 0.5rem 1rem 0;
}

/* === The language logo ================================================== */

/*
 * 20px, always left, always in its brand colour. The previous rule sized every
 * [class$="_icon"] at 18px with a 0.5rem right margin; spacing now comes from
 * the toolbar's gap, which survives markup edits.
 */
.snippet_logo,
[class$="_icon"] {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  color: var(--lang-color, var(--mk-n-500));
}

/*
 * Light brand colours (JavaScript #f0d91e, Docusaurus #3ecc5f, Batch #8bad2e)
 * float on the sand toolbar. A 12%-black hairline anchors them without
 * repainting the mark — the only concession the logos make to the theme.
 */
.variant_js .snippet_logo,
.variant_docusaurus .snippet_logo,
.variant_batch .snippet_logo {
  filter: drop-shadow(0 0 0.5px rgb(0, 0, 0, 0.45));
}

/* === Variant base + per-language brand colours =========================== */

/*
 * Border colours map to each language/tool's own brand identity (Docker blue,
 * PHP purple, GitHub's linguist palette…) — external identities, not site UI,
 * so they intentionally don't follow the light/dark theme. Legitimate
 * color-no-hex exception, see AGENTS.md and
 * .todos/039-hardcoded-hex-colors-no-token-system.md.
 *
 * Values unchanged from the previous version of this file. The old rule also
 * set `color: #fff` on every variant, which was dead weight: no text in these
 * blocks sits on the brand colour.
 */
/* stylelint-disable color-no-hex */
[class^="variant_"] {
  --lang-color: var(--mk-n-500);
  border-top: 3px solid var(--lang-color);
}

.variant_apacheconf {
  --lang-color: #d22128;
}
.variant_asm {
  --lang-color: #6e4c13;
}
.variant_bash {
  --lang-color: #3eaaaf;
}
.variant_batch {
  --lang-color: #8bad2e;
}
.variant_csv {
  --lang-color: #2d7f5e;
}
.variant_css {
  --lang-color: #1e4fb4;
}
.variant_diff {
  --lang-color: #e36209;
}
.variant_docker {
  --lang-color: #0aa2ce;
}
.variant_docusaurus {
  --lang-color: #3ecc5f;
}
.variant_gherkin {
  --lang-color: #5a9a00;
}
.variant_html {
  --lang-color: #e44d26;
}
.variant_ignore {
  --lang-color: #6a737d;
}
.variant_ini {
  --lang-color: #008080;
}
.variant_java {
  --lang-color: #e11e2b;
}
.variant_js {
  --lang-color: #f0d91e;
}
.variant_json {
  --lang-color: #ad8b00;
}
.variant_log {
  --lang-color: #808080;
}
.variant_makefile {
  --lang-color: #6e4f1e;
}
.variant_md {
  --lang-color: #083fa1;
}
.variant_none {
  --lang-color: #aaaaaa;
}
.variant_pascal {
  --lang-color: #4278b8;
}
.variant_php {
  --lang-color: #7a87b9;
}
.variant_powershell {
  --lang-color: #0078d7;
}
.variant_python {
  --lang-color: #2c628d;
}
.variant_sql {
  --lang-color: #e38c00;
}
.variant_svg {
  --lang-color: #f04e23;
}
.variant_toml {
  --lang-color: #f36d25;
}
.variant_ts {
  --lang-color: #3178c6;
}
.variant_txt {
  --lang-color: #6a737d;
}
.variant_vb {
  --lang-color: #571a66;
}
.variant_vbnet {
  --lang-color: #68217a;
}
.variant_xml {
  --lang-color: #bf3b1b;
}
.variant_yaml {
  --lang-color: #a31416;
}
/* stylelint-enable color-no-hex */

/* ═══════════════════════════════════════════════════════════════════════════
   ELI5 — line-by-line renderer
   Still deliberately dark-optimised regardless of site theme (it sits on a
   Prism background), but now on the palette's own warm black and with the
   badge/tooltip using tokens instead of six rgba() literals.
   ═══════════════════════════════════════════════════════════════════════════ */

.eli5_pre {
  position: relative;
  margin: 0;
  padding: 0.75rem 0;
  overflow-x: auto;
  font-family: var(--ifm-font-family-monospace);
  font-size: var(--ifm-code-font-size, 0.875rem);
  line-height: 1.5;
  color: var(--mk-code-fg);
  background: var(--mk-code-bg);
  border-radius: 0 0 var(--mk-radius-md) var(--mk-radius-md);
}

.eli5_line {
  display: flex;
  align-items: flex-start;
  min-height: 1.5em;
  padding: 0 1rem;
}

.eli5_line:hover {
  background: rgb(255, 255, 255, 0.04);
}

.eli5_code {
  flex: 1;
  overflow: hidden;
  white-space: pre;
}

.eli5_badge_placeholder {
  display: inline-block;
  width: 1.2rem;
  flex-shrink: 0;
  margin-left: 0.5rem;
}

.eli5_badge_wrapper {
  position: relative;
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  margin-left: 0.5rem;
}

/* The "?" badge was indigo-tinted — the one cool accent left in the site.
 * It now uses the savanna green that the Terminal prompt uses. */
.eli5_badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.2rem;
  height: 1.2rem;
  padding: 0;
  font-size: 0.6rem;
  font-weight: 700;
  line-height: 1;
  color: var(--mk-term-prompt);
  cursor: pointer;
  background: rgb(147, 183, 100, 0.14);
  border: 1px solid rgb(147, 183, 100, 0.45);
  border-radius: 50%;
  flex-shrink: 0;
  transition:
    background 0.15s ease,
    color 0.15s ease,
    border-color 0.15s ease;
}

.eli5_badge:hover,
.eli5_badge:focus-visible,
.eli5_badge_active {
  color: var(--mk-code-bg);
  background: var(--mk-term-prompt);
  border-color: var(--mk-term-prompt);
  outline: none;
}

.eli5_copy_button {
  position: absolute;
  top: 0.6rem;
  right: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  padding: 0.35rem;
  color: var(--mk-n-500);
  cursor: pointer;
  background: rgb(255, 255, 255, 0.06);
  border: 1px solid rgb(255, 255, 255, 0.15);
  border-radius: var(--mk-radius-sm);
  opacity: 0;
  transition:
    opacity 0.15s ease-in-out,
    background 0.15s ease-in-out,
    color 0.15s ease-in-out;
}

.eli5_copy_button:hover,
.eli5_copy_button:focus-visible {
  color: var(--mk-code-fg);
  background: rgb(255, 255, 255, 0.12);
  opacity: 1;
  outline: none;
}

/* :where() zeroes specificity so this never outranks the button's own hover
 * state above — see the original note, the reasoning is unchanged. */
/* stylelint-disable-next-line no-descending-specificity */
:where(.eli5_pre:hover) .eli5_copy_button {
  opacity: 0.55;
}

.eli5_copy_icon {
  width: 1.1rem;
  height: 1.1rem;
}

.eli5_tooltip {
  position: fixed;
  z-index: 9999;
  width: 270px;
  max-width: min(270px, 90vw);
  padding: 0.5rem 0.75rem;
  font-family: var(--ifm-font-family-base);
  font-size: 0.8rem;
  line-height: 1.55;
  color: var(--mk-ink);
  white-space: normal;
  pointer-events: none;
  background: var(--mk-surface);
  border: 1px solid var(--mk-border);
  border-radius: var(--mk-radius-md);
  box-shadow: var(--mk-shadow-3);
  transform: translateY(calc(-100% - 8px));
}

.eli5_tooltip::before {
  position: absolute;
  bottom: -5px;
  right: 4px;
  width: 8px;
  height: 8px;
  content: "";
  background: var(--mk-surface);
  border-right: 1px solid var(--mk-border);
  border-bottom: 1px solid var(--mk-border);
  transform: rotate(45deg);
}

.eli5_summary_block {
  border-top: 1px solid var(--mk-border);
}

.eli5_summary_toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 0.4rem 1rem;
  font-size: 0.8rem;
  font-weight: 500;
  color: var(--mk-ink-soft);
  cursor: pointer;
  background: none;
  border: none;
}

.eli5_summary_toggle_right {
  display: inline-flex;
  gap: 0.5rem;
  align-items: center;
}

.eli5_summary_badge {
  composes: mk-eyebrow from global;
  font-size: 0.65rem;
  font-weight: 500;
}

.eli5_summary_toggle:hover {
  background: var(--ifm-hover-overlay);
}

.eli5_summary_toggle:focus-visible {
  outline: 2px solid var(--mk-primary);
  outline-offset: -2px;
}

.eli5_summary_content {
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.eli5_summary_text {
  margin: 0;
  padding: 0 1rem 0.75rem;
  font-size: 0.85rem;
  line-height: 1.6;
  color: var(--mk-ink);
}
```

The last thing we should do is to teach Docusaurus about our custom component.

Please also edit the `src/theme/MDXComponents.js` file (and if not present, please create it)

**src/theme/MDXComponents.js**

```javascript
import MDXComponents from "@theme-original/MDXComponents";
import Snippets from "@site/src/components/Snippets";

export default {
  // Reusing the default mapping
  ...MDXComponents,
  Snippets,
};
```

### LogoIcon of Docux

You'll also need the [LogoIcon](https://docuxlab.com/blog/logoicon-component-docusaurus/) created by ![Docux](/img/docux.webp) [Docux](https://github.com/Juniors017).

This component makes it easy to retrieve an SVG icon for a language (let's say Python) and display it. Behind the scenes, LogoIcon is using [Iconify](https://icon-sets.iconify.design/).

In very short:

1. Please run `yarn add @iconify/react` to install the iconify library

2. Please create this file `src/components/Blog/LogoIcon/index.tsx`

**src/components/Blog/LogoIcon/index.tsx**

```typescript
import type { JSX } from "react";
import { Icon, addCollection } from "@iconify/react";
import { iconCollections } from "./iconBundle.generated";

// Register the bundled icon data once, at module load time, so it is already available
// (synchronously, on both server and client) before the first  ever renders. This
// avoids the React hydration mismatch (#418) caused by @iconify/react's default behaviour
// of fetching icon data from its API asynchronously: SSR would render an empty placeholder
// while the client, once the fetch resolved, rendered the actual SVG.
Object.values(iconCollections).forEach((collection) => addCollection(collection));

interface Props {
  name: string;
  size?: number | string;
  className?: string;
  "aria-label"?: string;
}

export default function LogoIcon({
  name,
  size = 48,
  className,
  "aria-label": ariaLabel,
}: Props): JSX.Element {
  return (
    
  );
}
```

## Using the Snippets component

Now, if you want to add a snippet in your blog, just do something like this:

```html

  name = input("What's your name? ") if name: print(f"Hello, {name}!") else:
  print("Hello, stranger!") print("Nice to meet you.")

```

And it'll be rendered like this:

**who\_are\_you.py**

```python
name = input("What's your name? ")

if name:
    print(f"Hello, {name}!")
else:
    print("Hello, stranger!")

print("Nice to meet you.")
```

## Don't copy/paste content anymore

Let's do much, much better: don't copy/paste the code to the article but read it from the disk.

Let's imagine this:

``

* `filename` is thus the title to show in the article, so the reader knows the file should be named like that
* `source` is the relative path (from your Docusaurus root folder) when the file can be retrieved. In this scenario, we don't have to put the source code in the file but Docusaurus will do the job for us:
  * When previewing the site (dev mode), a plugin will read the content immediately from the disk and will inject its content. So, if the sourced file is updated, your article will always be up-to-date
  * When building the static version (prod mode), the Docusaurus build engine will also read the content of the file from the disk and inject it in your article.

> **Caution:**
>
> You have two types of paths: from your root folder or relative to the blog post.
>
> If you use the `source="./files/example.js"` syntax (the path is starting with a dot), the file will be relative to your blog post.
>
> The second possible syntax is without the dot like `source="src/components/Snippet/index.tsx"` and, in this case, the filename will be relative to your Docusaurus root folder i.e. the folder where the `docusaurus.config.js` file is saved.

### To make this work, we need a plugin

In Docusaurus vocabulary, we need a plugin to do this i.e. read the file's content from the disk and "inject" it in the article.

So the plugin will parse the blog post, search for any `` call and check if there is a `src=` attribute. If so, the plugin will open the file and read its content. The plugin will also extract the file's extension (like `.js`). With these two pieces of information (content and extension), the plugin will inject the content into the `` tag, just as if you, the blog post author, had made a copy/paste.

The ultimate advantage here: your blog post will always be up-to-date; if you change the file on the disk, your post will automatically get the last version.

Please create the `plugins/remark-snippet-loader/index.cjs` with the content below:

**plugins/remark-snippet-loader/index.cjs**

```cjs
const fs = require("fs");
const path = require("path");
const { visit } = require("unist-util-visit");

const extensionToLang = {
  js: "javascript",
  jsx: "jsx",
  ts: "typescript",
  tsx: "typescript",
  py: "python",
  php: "php",
  sh: "bash",
  bash: "bash",
  // Prism has no "zsh" grammar: without these, a .zsh/.zshrc/.bashrc snippet
  // gets language-zsh / language-.zshrc and renders with zero highlighting.
  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",
  // Add more as needed
};

// Resolves a `source="…"` attribute the same way for every consumer: a
// `./`/`../`-prefixed path is relative to the file that carries the
// attribute, anything else is relative to the project root. Exported so the
// Markdown-export plugin (plugins/markdown-export-plugin/degrade.cjs) can
// inline the very same files without re-deriving this rule.
function resolveSourcePath(sourcePath, currentFileDir, projectRoot = process.cwd()) {
  if (sourcePath.startsWith("./") || sourcePath.startsWith("../")) {
    return path.resolve(currentFileDir, sourcePath);
  }
  return path.resolve(projectRoot, sourcePath);
}

// i18n//docusaurus-plugin-content-/… — the locale of the file being compiled.
// It has to come from the file's own path and not from the `source` attribute, because
// remark-i18n-assets runs FIRST and has already rewritten that attribute to point back at the
// English article's folder. The vfile path is the only thing left that still says "fr".
const I18N_LOCALE_RE =
  /(^|[/\\])i18n[/\\]([^/\\]+)[/\\]docusaurus-plugin-content-(?:blog|pages)(?:[/\\]|$)/;

/** The locale a file is compiled for, or null for the English sources. */
function localeOf(filePath) {
  const match = String(filePath ?? "").match(I18N_LOCALE_RE);
  return match ? match[2] : null;
}

/**
 * The ELI5 sidecar to read for this locale, preferring the localized one.
 *
 * Falling back to the English sidecar is deliberate (TODO 0121): a reader of a technical blog
 * reads English, and showing nothing would cost them information for the sake of visual
 * uniformity. The fallback only fires on a gap — a failed generation, or a snippet added to the
 * article after it was translated.
 */
function eli5PathFor(absolutePath, locale) {
  if (locale) {
    const localized = `${absolutePath}.eli5.${locale}.json`;
    if (fs.existsSync(localized)) return localized;
  }
  const english = `${absolutePath}.eli5.json`;
  return fs.existsSync(english) ? english : null;
}

function snippetLoader() {
  return (tree, vfile) => {
    // The absolute path of the currently processed .mdx/blog/doc file
    const blogPostPath = vfile.path;
    // Which locale this compilation is for — drives ELI5 sidecar selection below.
    const locale = localeOf(blogPostPath);
    // The directory of the .mdx/blog/doc file
    const currentFileDir = path.dirname(blogPostPath);
    // The Docusaurus project root (where docusaurus.config.js is located)
    const projectRoot = process.cwd();

    visit(tree, "mdxJsxFlowElement", (node) => {
      if (node.name !== "Snippet" && node.name !== "Terminal") return;

      const sourceAttr = node.attributes.find((attr) => attr.name === "source");
      if (!sourceAttr || typeof sourceAttr.value !== "string") return;

      const sourcePath = sourceAttr.value;
      const absolutePath = resolveSourcePath(sourcePath, currentFileDir, projectRoot);

      // Terminal: inject the file content as a text child of the node.
      // This reproduces exactly what ...inline content... does,
      // avoiding any issues with JSX attribute serialisation of multi-line strings.
      if (node.name === "Terminal") {
        // Intentionally not caught: a missing/unreadable source must fail the
        // build loudly, not degrade to a placeholder string that gets published.
        // See .todos/0106-migration-composants-js-vers-typescript.md — this is
        // exactly what silently broke 4 articles during the TS migration.
        let rawContent;
        try {
          rawContent = fs.readFileSync(absolutePath, "utf-8");
        } catch (err) {
          throw new Error(
            ` in ${blogPostPath} could not read ${absolutePath}: ${err.message}`,
            { cause: err },
          );
        }
        // Replace self-closing node with one that has a single child holding the
        // file content. It must be an *expression* child, not a text child: MDX
        // applies JSX whitespace rules to text children, which trims the leading
        // spaces of every line and destroys the indentation of the terminal
        // output. An expression carrying a plain string literal is emitted
        // verbatim, so indentation survives.
        node.children = [
          {
            type: "mdxFlowExpression",
            value: JSON.stringify(rawContent),
            data: {
              estree: {
                type: "Program",
                sourceType: "module",
                comments: [],
                body: [
                  {
                    type: "ExpressionStatement",
                    expression: { type: "Literal", value: rawContent },
                  },
                ],
              },
            },
          },
        ];
        return;
      }

      // Snippet: language detection, code injection, eli5.
      const filenameAttr = node.attributes.find((attr) => attr.name === "filename");

      // Reading the source itself is not caught: a missing/unreadable file must
      // fail the build loudly, not degrade to a "// Error loading..." comment
      // that gets published as if it were real code. See
      // .todos/0106-migration-composants-js-vers-typescript.md — this is
      // exactly what silently broke 4 articles during the TS migration.
      let code;
      try {
        code = fs.readFileSync(absolutePath, "utf-8");
      } catch (err) {
        throw new Error(
          ` in ${blogPostPath} could not read ${absolutePath}: ${err.message}`,
          { cause: err },
        );
      }

      const pathForLang =
        filenameAttr && typeof filenameAttr.value === "string"
          ? filenameAttr.value
          : sourcePath;

      // Determine extension for language detection
      const ext =
        path.extname(pathForLang).slice(1).toLowerCase() ||
        path.basename(pathForLang).toLowerCase();

      let lang = extensionToLang[ext];
      if (!lang) {
        // Try to detect language based on the base file name (useful for Dockerfile, compose.yaml)
        const baseName = path.basename(pathForLang).toLowerCase();
        if (baseName === "dockerfile") {
          lang = "docker";
        } else if (baseName.includes("compose.yaml") || baseName.includes(".yml")) {
          lang = "yaml";
        } else {
          lang = ext; // Use the extension or base name as fallback
        }
      }

      node.attributes.push({
        type: "mdxJsxAttribute",
        name: "code",
        value: code,
      });

      node.attributes.push({
        type: "mdxJsxAttribute",
        name: "lang",
        value: lang,
      });

      // Auto-inject ELI5 explanations if a .eli5.json file exists alongside the
      // source. This one stays a soft-fail: a missing/broken ELI5 cache is a content
      // nicety, not the code sample itself — losing it shouldn't fail the build.
      const eli5Path = eli5PathFor(absolutePath, locale);
      if (eli5Path) {
        try {
          const eli5Raw = fs.readFileSync(eli5Path, "utf-8");
          const eli5Data = JSON.parse(eli5Raw);
          if (eli5Data.explanations && typeof eli5Data.explanations === "object") {
            node.attributes.push({
              type: "mdxJsxAttribute",
              name: "eli5json",
              value: JSON.stringify(eli5Data.explanations),
            });
          }
          if (typeof eli5Data.summary === "string" && eli5Data.summary.trim()) {
            node.attributes.push({
              type: "mdxJsxAttribute",
              name: "eli5summary",
              value: eli5Data.summary.trim(),
            });
          }
        } catch (e) {
          console.warn(
            `Snippet plugin: could not parse ELI5 file ${eli5Path}:`,
            e.message,
          );
        }
      }
    });
  };
}

// Attached (not a named export) so the default-import used by
// docusaurus.config.js (`beforeDefaultRemarkPlugins: [remarkSnippetLoader, …]`)
// keeps seeing a plain callable plugin factory.
snippetLoader.resolveSourcePath = resolveSourcePath;

module.exports = snippetLoader;
```

Also, please edit your `docusaurus.config.js` file like this:

**docusaurus.config.js**

```javascript
// highlight-next-line
import remarkSnippetLoader from "./plugins/remark-snippet-loader/index.cjs";

const config = {
  // [ ... ]
  presets: [
    [
      "classic",
      {
        // [ ... ]
        blog: {
          beforeDefaultRemarkPlugins: [
            // [ ... ]
            // highlight-next-line
            remarkSnippetLoader,
          ],
        },
        // [ ... ]
      },
    ],
  ],
};

export default config;
```

This done, please restart your Docusaurus server and on the next start, if some changes have to be made, you can see them in your console:

## Demo, hardcoded content

In this section, I've copy/pasted the source inside the `` tag. Useful when I don't have the file on my disk.

**sample.apacheconf**

```apacheconf

    ServerName www.example.com
    DocumentRoot /www/domain

```

**sample.asm**

```asm
section .data
    msg db 'Hello, world!', 0Ah
section .text
    global _start
_start:
    mov edx, 13
```

**script.sh**

```bash
#!/bin/bash
echo "Hello, world!"
```

**script.bat**

```bat
@echo off
echo Hello, world!
```

**styles.css**

```css
body {
  background-color: #f0f0f0;
}
```

**data.csv**

```csv
name,age
Alice,30
Bob,25
```

**change.diff**

```diff
- old line
+ new line
```

**Dockerfile**

```docker
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
```

**login.feature**

```feature
Feature: User login
  Scenario: Valid login
    Given the user is on the login page
    When the user enters valid credentials
    Then they are redirected to the dashboard
```

**hello.html**

```html


  
    

Hello, world!

``` **.gitignore** ``` node_modules/ dist/ .env ``` **config.ini** ```ini [server] port=8080 host=localhost ``` **HelloWorld.java** ```java public class HelloWorld { public static void main(String[] args) { System.out.println("Hello, world!"); } } ``` **script.js** ```javascript console.log("Hello, world!"); ``` **data.json** ```json { "name": "example", "version": "1.0.0" } ``` **application.log** ```log [INFO] Server started at 10:00 [ERROR] Connection failed ``` **makefile** ``` build: \tgcc main.c -o app ``` **readme.md** ```markdown # Hello World This is a markdown example. ``` **hello\_world.pas** ```pas program HelloWorld; begin writeln('Hello, world!'); end. ``` **HelloWorld.php** ```php ``` **Hello\_World.ps1** ```ps1 Write-Output "Hello, world!" ``` **hello\_world.py** ```python print("Hello, world!") ``` **active.sql** ```sql SELECT * FROM users WHERE active = 1; ``` **logo.svg** ```svg ``` **pyproject.toml** ```toml [package] name = "example" version = "0.1.0" ``` **HelloWorld.vb** ```vb Sub Main() Console.WriteLine("Hello, world!") End Sub End Module ``` **message.xml** ```xml Hello, world! ``` **config.yaml** ```yaml name: Example version: 1.0.0 ``` ## Demo, get content from the disk Unlike the previous chapter, here, the syntax `` has been used. So, the source code is injected dynamically during the previewing/rendering of the blog. If the source file is updated, the article will be updated too. Both are synchronized. **src/components/Blog/PostCard/index.tsx** ```typescript import type { JSX } from "react"; import Card from "@site/src/components/Card"; import CardBody from "@site/src/components/Card/CardBody"; import CardImage from "@site/src/components/Card/CardImage"; import Link from "@docusaurus/Link"; import Translate, { translate } from "@docusaurus/Translate"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import useBaseUrl from "@docusaurus/useBaseUrl"; import { formatPostDate } from "@site/src/components/Blog/utils/date"; import { createSlug } from "@site/src/components/Blog/utils/slug"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import styles from "./styles.module.css"; interface Post { permalink: string; image?: string; title: string; // `null` is accepted alongside `undefined`: LatestPosts / RelatedPosts pass // `{ ...post, description: null }` to render a card without its description, // and getBlogMetadata() types `mainTag` as `string | null`. description?: string | null; date?: string; counter?: string; mainTag?: string | null; readingTime?: number; } interface Props { post: Post; layout?: "big" | "small"; defaultImage?: string; lazyImage?: boolean; } export default function PostCard({ post, layout = "big", defaultImage = "/img/default.jpg", lazyImage = true, }: Props): JSX.Element { const { permalink, image, title, description, date, counter, mainTag, readingTime } = post; const { i18n } = useDocusaurusContext(); const formattedDate = formatPostDate(date, i18n.currentLocale); const tagLabel = useTagLabel(); // `image` is a front-matter path (`/img/v2/atuin.webp`) — a string this repo wrote, not one // Docusaurus handed us, so it does NOT carry the locale's baseUrl. Under `fr` the static file // is served from `/fr/img/...`; requesting `/img/...` gets the dev server's SPA fallback, // which answers `200 text/html` and paints a broken banner rather than 404-ing visibly. // Only the "small" layout needs this: the "big" one goes through ``, which calls // `useBaseUrl` itself. const smallLayoutImageUrl = useBaseUrl(image || defaultImage); if (layout === "small") { return (
{title}

{title}

{counter &&
{counter}
} {formattedDate &&

{formattedDate}

}
Read more
); } // Default "big" layout return (
{mainTag && ( {tagLabel(mainTag)} )}

{title} →

{description &&

{description}

} {counter &&

{counter}

} {(formattedDate || readingTime) && (

{formattedDate && {formattedDate}} {readingTime && ( {" "} ·{" "} {"{minutes} min read"} )}

)}
); } ``` **src/components/Blog/PostCard/readme.md** ````markdown # 📄 PostCard Component A reusable React component for Docusaurus that renders a blog post preview card with image, title and description. ## 📦 Location Place the component in your Docusaurus project: ```bash src/components/Blog/PostCard/index.tsx src/components/Blog/PostCard/styles.module.css ``` ## ✨ Features - Displays: - Featured image (with fallback) - Optional tag badge (real link, not a nested fake link) when `post.mainTag` is set - Title (linked to the post) - Short description - Accessible markup with semantic link and alt text - Customizable fallback image via `defaultImage` prop - Optional eager-loading of the image via `lazyImage` (useful for above-the-fold cards) - Uses Infima utility classes and optional CSS module styling ## 🚀 Usage ```jsx import PostCard from '@site/src/components/Blog/PostCard'; ``` ## 🧾 Props | Prop | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `post` | object | ✅ | — | Blog post metadata (see structure below) | | `layout` | `"big"` \| `"small"` | ❌ | `"big"` | Card variant to render | | `defaultImage` | string | ❌ | `/img/default.jpg` | Fallback image path if `post.image` is missing | | `lazyImage` | boolean | ❌ | `true` | Set to `false` for above-the-fold cards to avoid hurting LCP | ### post object structure ```js { permalink: "/blog/my-post", image: "/img/my-post.webp", title: "My Blog Post", description: "A short summary of the post.", mainTag: "docker" // optional, renders a tag badge on the "big" layout } ``` ## 🧠 Accessibility Notes - The title is wrapped in a `` with an aria-label for screen reader clarity. - The tag badge is a real `` (not a nested fake link), and sits as a sibling of the title link rather than inside it — no interactive element is ever nested inside another. - The image includes alt and title attributes for better context. ## 🎨 Styling Custom styles can be added via `styles.module.css`. Example: `.description` class for paragraph styling. ## 🧩 Related Components - `Card` - `CardBody` - `CardImage` ## 📄 License MIT — free to use, modify, and contribute. ## 💬 AI generated This code has been generated by Christophe Avonture using AI. ```` **src/components/Blog/PostCard/styles.module.css** ```css /* * PostCard. * * Note the removal of the :root block at the top of the old file: it declared * --card-radius / --card-transition / --card-hover-transform from inside a CSS * module, and Blog/Updated consumed --card-radius through a fallback. Two * unrelated components were coupled through a variable whose definition * depended on PostCard being on the page. Those now live in theme.css. * * The tag badge was a filled terracotta pill — the loudest thing in a grid of * twenty cards. It becomes the shared .mk-pill: cream ground, terracotta text. */ .cardEnhanced { composes: mk-surface from global; composes: mk-liftable from global; display: flex; flex-direction: column; height: 100%; min-width: 0; overflow: hidden; } .cardBodyEnhanced { display: flex; flex: 1; flex-direction: column; gap: 0.5rem; padding: 1rem; } .cardImageEnhanced { display: block; width: 100%; aspect-ratio: 2 / 1; object-fit: contain; background: var(--mk-n-100); transition: transform 0.2s ease; } .cardEnhanced:hover .cardImageEnhanced { transform: scale(1.03); } .cardTitle { display: -webkit-box; margin: 0; overflow: hidden; font-family: var(--mk-font-display); font-size: 1.02rem; font-weight: 700; line-height: 1.35; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .cardTitleLink { color: var(--mk-ink); text-decoration: none; transition: color 0.15s ease; } .cardTitleLink:hover { color: var(--mk-primary); text-decoration: none; } .cardTagBadge { composes: mk-pill from global; align-self: flex-start; } /* * The date was italic and the reading time italic too — two italics in a * four-word line. Both are now plain, quiet, and tabular so the dates line up * from card to card in a grid. */ .date { margin-bottom: 0; font-size: 0.82rem; color: var(--mk-ink-soft); font-variant-numeric: tabular-nums; } .readingTime { font-variant-numeric: tabular-nums; } .description { display: -webkit-box; margin: 0; overflow: hidden; font-size: 0.88rem; line-height: 1.55; color: var(--mk-ink-soft); -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .counter { composes: mk-pill from global; margin-top: 0.5rem; } /* ── Small layout ─────────────────────────────────────── */ .cardSmallWrapper { display: flex; margin-bottom: 2rem; } .cardSmall { display: flex; flex-direction: column; width: 100%; height: 100%; } .cardSmallImage { width: 100%; aspect-ratio: 2 / 1; object-fit: contain; background: var(--mk-n-100); border-radius: var(--mk-radius-lg) var(--mk-radius-lg) 0 0; } .cardSmallBody { flex: 1; } .cardSmallCounter { composes: mk-eyebrow from global; margin-bottom: 0.375rem; color: var(--mk-primary); } .cardSmallFooter { text-align: right; } ``` And, last demo, retrieve the content of file relative to this blog post **(so the source filename starts here with a dot to tell it's relative to the `.md` file)**: **./files/hello\_world.py** ```python def greet_user(name): print(f"Hello, {name}! Welcome to the Python world.") def main(): name = input("What's your name? ") if name.strip(): greet_user(name) else: print("You didn't enter a name. Try again!") if __name__ == "__main__": main() ``` ## Go further with ProjectSetup The `Snippet` component is a great way to display code snippets in your Docusaurus blog, but what if you want to share an entire project structure with your readers? That's where the `ProjectSetup` component comes in. Continue reading to learn about the `ProjectSetup` component, which allows you to display a project's file structure in a clean, interactive, and standardized way. It not only shows the files and their content but also provides tools to scaffold the project with a single command or download it as a ZIP file. [Introducing the ProjectSetup Component - A Standardized Way to Share Project Structures](/blog/docusaurus-project-setup) --- # A Docusaurus component that alerts readers the article is over a year old > Displays a warning banner when the page content is over a year old --- ![A Docusaurus component that alerts readers the article is over a year old](/img/v2/old_blog_notice.webp) **TL;DR** This article demonstrates creating a Docusaurus component to alert readers when a blog post is over a year old. It involves building a React component and "swizzling" the `BlogPostPage` template to inject this notice. The component determines an article's age by checking its `last_update` or `date` field in the front matter. In this article, let's see how we can display a small frame "Warning: This article is at least a year old, which in tech years is basically prehistoric." We'll create a React component for Docusaurus, and we'll inject it in our blog post page so, once in place, everything will work automagically. Here it is in action, on an old post of this very blog: > Screenshot — https\://www\.avonture.be/blog/joomla-show-table > > Old post notice in action Let's build it. ## Create the component You'll need to create two new files in your own Docusaurus site: **Project: /your\_docusaurus\_site** **src/components/Blog/OldPostNotice/index.tsx** ```typescript /** * OldPostNotice * * A Docusaurus blog component that displays a visual warning if the article * is older than one year. This helps readers understand that the content may * be outdated and should be interpreted with caution. * * Features: * - Uses `useBlogPost` to access the post's metadata and publication date. * - Calculates whether the post is older than one year. * - Renders the shared .mk-callout primitive (see src/css/theme.css) through * styles.module.css — NOT Docusaurus's own alert classes. * * Usage: * Import and include this component inside your swizzled `BlogPostItem/Content/index.js` * to automatically show the notice on older posts. * * Styling: * Additional styles can be customized via `styles.module.css` in the same folder. */ import type { JSX } from "react"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import { useDateTimeFormat } from "@docusaurus/theme-common/internal"; import Translate from "@docusaurus/Translate"; import styles from "./styles.module.css"; export default function OldPostNotice(): JSX.Element | null { const { metadata } = useBlogPost(); const { date, frontMatter } = metadata; // Declared here, above every early return: it is a hook. `timeZone: "UTC"` because // `review_date` is a date-only front-matter string parsed as UTC midnight. const dateTimeFormat = useDateTimeFormat({ year: "numeric", month: "long", day: "numeric", timeZone: "UTC", }); // `updates` and `review_date` are this blog's own frontmatter fields (see CLAUDE.md), // not part of Docusaurus's own BlogPostFrontMatter type — hence the casts. const updates = frontMatter.updates as { date: string; note: string }[] | undefined; const reviewDate = frontMatter.review_date as string | undefined; let lastUpdated = date; if (updates && updates.length > 0) { const mostRecentUpdate = [...updates].sort( (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), )[0]; lastUpdated = mostRecentUpdate.date; } const postDate = new Date(lastUpdated); const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); const isOld = postDate < oneYearAgo; if (!isOld) { return null; } if (reviewDate) { const reviewDateObj = new Date(reviewDate); const isReviewRecent = reviewDateObj >= oneYearAgo; if (isReviewRecent) { // `useDateTimeFormat` follows the locale being rendered; the hardcoded "en-US" it // replaces printed "September 14, 2026" inside an otherwise French notice. const formattedDate = dateTimeFormat.format(reviewDateObj); return (

{formattedDate} }} > {"This article is over a year old but was reviewed on {date} — the content is still accurate."}

); } } return (

This article is over a year old. The information may be outdated.

); } ``` **src/components/Blog/OldPostNotice/styles.module.css** ```css /* * OldPostNotice — was using Infima's warning/success contrast pairs, a fifth * colour system in the site. Both notices are callouts now, so a reader meets * the same object whether the message comes from an article's author or from * the site itself. * * index.tsx no longer adds "alert alert--warning" / "alert alert--success" on * top of these classes: Infima's .alert sets its own background, full border * and text colour, so the two systems were stacking and neither won cleanly. */ .oldPostNotice { composes: mk-callout from global; composes: mk-callout--warn from global; margin: 0 0 1.5rem; } .reviewedPostNotice { composes: mk-callout from global; composes: mk-callout--tip from global; margin: 0 0 1.5rem; } .icon { composes: mk-dot from global; } .body { min-width: 0; margin: 0; font-size: 0.95rem; line-height: 1.6; } /* The date inside the "reviewed" notice. The old rule added a right margin to * every , which also indented the last word of a sentence ending on * one — the flex row and the sentence's own spaces handle this. */ .body strong { font-weight: 600; } ``` ## Override BlogPostPage template In terms of Docusaurus, we'll need to **swizzle** the page that is responsible for rendering a post. That page is called `BlogPostPage`. To do this, please start a console and run `yarn swizzle @docusaurus/theme-classic BlogPostItem/Content`. From now, you'll have a new file on your disk: `src/theme/BlogPostItem/Content/index.js` > **You may already have that file:** > > [Showing the changelog of your post](/blog/docusaurus-changelog) swizzles exactly the same file. If you've followed that article, just add the new component next to the existing one instead of swizzling again. Below, the original content of the file (Docusaurus v3.8.1): **src/theme/BlogPostItem/Content/index.js** ```javascript import React from 'react'; import clsx from 'clsx'; import {blogPostContainerID} from '@docusaurus/utils-common'; import {useBlogPost} from '@docusaurus/plugin-content-blog/client'; import MDXContent from '@theme/MDXContent'; export default function BlogPostItemContent({children, className}) { const {isBlogPostPage} = useBlogPost(); return (
{children}
); } ``` To inject our new `OldPostNotice` component, please edit the file like this (see highlighted lines): **src/theme/BlogPostItem/Content/index.js** ```javascript import React from 'react'; import clsx from 'clsx'; import {blogPostContainerID} from '@docusaurus/utils-common'; import {useBlogPost} from '@docusaurus/plugin-content-blog/client'; import MDXContent from '@theme/MDXContent'; // highlight-next-line import OldPostNotice from "@site/src/components/Blog/OldPostNotice"; export default function BlogPostItemContent({children, className}) { const {isBlogPostPage} = useBlogPost(); return ( // highlight-next-line <> // highlight-next-line
{children}
// highlight-next-line ); } ``` Save the file and refresh your blog. You should get the same banner already teased at the top of this article. > **Information:** > > The component is using the `date` field that you have to mention in your YAML front matter. > > So, in each of your `.md` post, you should have a YAML block like this: > > ```yaml > --- > title: Your blog post article > authors: [you] > date: 2025-09-30 > --- > ``` ## Marking a reviewed post If you revisit an old article and confirm the content is still accurate, you can add a `review_date` field to the front matter: ```yaml --- date: 2023-05-10 review_date: 2026-07-30 --- ``` When `review_date` is present **and less than a year old**, the component swaps the orange warning for a green confirmation banner: > ✅ This article is over a year old but was reviewed on July 30, 2026 — the content is still accurate. If `review_date` itself is over a year old, the standard warning reappears — because a stale review is no better than no review at all. ## Position of the warning The notice will be placed before the post content. On my blog, I've manipulated the code below to extract the very first image (i.e. the banner) so I can display it first, then the notice information, then the rest of the article. But that's probably too complex right now. We'll see this in a future article. *The banner image is treated differently from the other ones for exactly the same reason in [Change how Docusaurus will create img tags](/blog/docusaurus-override-img): the first image of a post is special and should be left alone.* ```jsx
{children}
``` --- # Showing the changelog of your post > Showing the changelog of your post --- ![Showing the changelog of your post](/img/v2/changelog.webp) **TL;DR** This article demonstrates how to implement a detailed changelog for Docusaurus blog posts, extending beyond the default `last_update` field. It involves creating a custom React component that parses an `updates` array from the post's YAML frontmatter, allowing for multiple dated notes. The solution requires creating this component and then swizzling the `BlogPostItem/Content` theme to automatically display the update history. As a blog author, no doubt, it happens that you'll edit previous posts to add / clarify some part. Or just remove obsolete information. Docusaurus supports the `last_update` ([source](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs#last_update)) entry like illustrated below, but you can only specify one date and no description. ```yaml last_update: date: 1/1/2000 author: custom author name ``` Let's create a very small component to enhance this. ## The Result Once the component is wired in (covered below), adding an `updates` entry to a post's frontmatter renders this, automatically: ![Demo](./images/sample.webp) ## Why It Works * The component reads an `updates` array straight from the post's YAML frontmatter — no separate changelog file to maintain. * Unlike Docusaurus's native `last_update`, it accepts multiple entries, each with its own `date` and `note`. * Once swizzled in, it displays automatically on every post that has an `updates` entry — nothing to call manually per-article. ## Installation We'll have to create a new component, and to swizzle the BlogPostPage, so the component will be executed automatically. ### Create the Updated component Please create a new folder called `src/components/Blog/Updated` where you'll create two files: `index.tsx` and `styles.module.css`. **src/components/Blog/Updated/index.tsx** ```typescript import type { JSX } from "react"; import clsx from "clsx"; import Translate from "@docusaurus/Translate"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { parseMarkdown } from "@site/src/components/Blog/utils/markdown"; import styles from "./styles.module.css"; interface UpdateEntry { date: string; note: string; } interface Props { updates?: UpdateEntry[]; } export default function Updated({ updates }: Props): JSX.Element | null { const { i18n } = useDocusaurusContext(); if (!updates || updates.length === 0) return null; const sortedUpdates = [...updates].sort( (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), ); return (

{" "} Changelog

{sortedUpdates.map((update, i) => { const isLatest = i === 0; return (
{isLatest && ( Latest )}
); })}
); } ``` **src/components/Blog/Updated/styles.module.css** ```css /* * Updated — the timeline structure is good and is kept as-is. What changed: * it no longer borrows --card-radius from PostCard's module (see that file), * the timeline node is the shared .mk-dot, and the title is an eyebrow rather * than a 1.1rem bold heading competing with the article's own. */ .revisionHistoryContainer { composes: mk-surface from global; padding: 1.25rem 1.375rem; } .revisionHistoryTitle { composes: mk-eyebrow from global; display: flex; gap: 0.5rem; align-items: center; margin: 0 0 1rem; } .timeline { position: relative; } /* The connecting line: centred on the 26px node (13px) rather than the old * 30px one (15px). */ .timeline::before { position: absolute; top: 4px; left: 13px; width: 2px; height: calc(100% - 8px); content: ""; background: var(--mk-border); } .timelineItem { position: relative; display: flex; min-height: 26px; margin-bottom: 1.25rem; } .timelineItem:last-child { margin-bottom: 0; } .timelineIcon { composes: mk-dot from global; position: absolute; top: 0; left: 0; z-index: 1; color: var(--mk-on-line); background: var(--mk-n-300); } /* The latest revision is the only filled terracotta node, with a halo in the * key tone — the halo used to be a color-mix() of the primary at 15%. */ .timelineItemLatest .timelineIcon { background: var(--mk-primary); box-shadow: 0 0 0 4px var(--mk-key-bg); } .timelineContent { flex: 1; padding-top: 0.15rem; padding-left: 40px; } .timelineDate { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.3rem; font-size: 0.85rem; font-weight: 600; color: var(--mk-ink-soft); font-variant-numeric: tabular-nums; } .timelineItemLatest .timelineDate { color: var(--mk-primary); } .latestBadge { composes: mk-pill from global; } .timelineDescription { font-size: 0.95rem; line-height: 1.6; color: var(--mk-ink); } ``` Shortly, the component will check if the YAML frontmatter of your blog post contains an `updates` entry. If this is the case, it will parse it. It has to be an array with two pieces of information: a `date` and a `note`. ### Override the BlogPostPage template Please first check if you don't have the `src/theme/BlogPostItem/Content/index.js` file yet. If you're a reader of this blog and already have customized Docusaurus, perhaps you have it already: [the "this article is over a year old" component](/blog/docusaurus-old-notice) swizzles that very same file. If not, please start a console and run `yarn swizzle @docusaurus/theme-classic BlogPostItem/Content`. From now, you'll have a new file on your disk: `src/theme/BlogPostItem/Content/index.js`. We'll have to inject our new `Updated` component, please edit the `index.js` file to inject the required code. Below are the minimum lines to add at the right place in your own file. **src/theme/BlogPostItem/Content/index.js** ```javascript // [...] import Updated from "@site/src/components/Blog/Updated"; export default function BlogPostContent({ children }) { // [...] const { metadata, isBlogPostPage } = useBlogPost(); // [...] return (
// [...] {isBlogPostPage && ( <> // [...] )} // [...]
); } ``` ## More Demos ### Use the component Now, just edit any of your existing posts and add an `updates` entry in your YAML front matter like for instance ```yaml updates: - date: 2024-11-19 note: review Dockerfile, use Quarto 1.6.36. - date: 2024-12-01 note: Added new theme options for blog posts. - date: 2024-12-15 note: Fixed bug with image rendering on mobile devices. - date: 2025-01-05 note: Implemented search functionality for documentation pages. - date: 2025-01-20 note: Optimized build process for faster deployment times. ``` Just save the post and refresh your web page. If you've done everything correctly, you'll see the information in your browser — the same rendering shown at the top of this article. Feel free to update the `src/components/Blog/Updated/styles.module.css` to match your own styling preferences. ## Conclusion A small parsed frontmatter array and one swizzled theme file replace `last_update`'s single date with a real, multi-entry changelog — readers see exactly what changed and when, without you maintaining a separate file. It pairs nicely with [the banner warning readers that an article is over a year old](/blog/docusaurus-old-notice): the first says *what* changed, the second says *when it last did*. --- # Inject ASCII Art in any HTML pages rendered by Docusaurus > Inject an ASCII-art banner into every generated Docusaurus HTML page using a lightweight postBuild plugin. Appears in page source or can be rendered visibly. --- ![Inject ASCII Art in any HTML pages rendered by Docusaurus](/img/v2/ascii_art_html.webp) **TL;DR** This article demonstrates how to inject a custom ASCII art banner into every HTML page generated by Docusaurus. It involves creating a lightweight `postBuild` plugin that scans generated files and inserts the ASCII art as an HTML comment after the `` tag. The guide provides steps for creating the ASCII art file, the plugin, and configuring Docusaurus to activate it. It might be entirely useless, and only the most dedicated tech-heads will spot it, but we're going to dive into how to inject a custom ASCII art banner at the very top of every HTML page generated for our blog. Right now, just press `CTRL`+`U` (it's the same as `View page source`) to see what we'll do in this article. ## The Result Once the plugin is in place (covered below), pressing `CTRL`+`U` on any page of the generated site shows this, right after the `` tag: ![HTML meerkat](./images/html_meerkat.webp) > **Your website is demonstrably world-class and utterly unique in its field. ;-):** ## Why It Works * The plugin hooks into Docusaurus's `postBuild` event — it only runs once `yarn docusaurus build` has finished writing HTML to disk. * It scans every generated `.html` file in the build output. * For each one, it injects the ASCII art as an HTML comment right after the `` opening tag — invisible on the rendered page, visible only in the page source. *Unlike the remark plugins I've written before (see [Creating a search\&replace plugin for Docusaurus](/blog/docusaurus-plugin-replace)), this one doesn't touch the Markdown at all: it works on the generated HTML.* ## Installation ### Create your personalized logo First, we have to get some ASCII art. If you don't have one yet, you can use this online tool: [Image to ASCII Art Converter](https://folge.me/tools/image-to-ascii). Just upload a small character on it and convert it. *I've also covered the command-line way of producing such banners in [Bash - ASCII art](/blog/bash-ascii-art).* In your Docusaurus site, please create the `src/data/banner.txt` file and paste your ASCII art in it. Here is mine: **src/data/banner.txt** ```txt :+***###%%%%%%%%##****=: .-*##%%%%%%##%%%%%%%%%%%%%%%%%%%%##+. .=#%%%#%#%+.:..........::--------=#%%%%%%%%*: .+#%%%##:........:------------------------+%%@@@@@:. ..=%#%##=......:----------------------------------*%%@@@#.. .:#%##*=.....:----:---------------------------=#@@%+=--*%@@@%-. :####+.....:-+%@@@*=:-------------------------=@@@@@@@+----#@@@%=. :#%%#+....:::*@@@@@@@@:-------------------------=**+-+##=------#@@@%- .*#%%+....:::::=#%*--++-------------------------------------------=#@@@#: -#%%*....:::::::::------------------------------------=++++++=-----===%@@#- =#%%=...:::::::--=++++++----------------------------+************+=--===*@@%= +%%%-...:-:::-++************+-----------------------******************====*@@@=. +%%%-..:-----******************+--------------------****#%%%%%%##*******+===*@@@= =%%%-..:----**********%@@@@@@****+------------------***%@@@%**%@@@@@*******===*@@@= -#%%-..:----*******#@@@@%*=-=%@@@%**-----------------*#@#-::....::-#%@@#*****===*@@@%%=. ...=#%%=...:--=******#%@@+-::.....::-%@%*----------------*%-::+@@#-.....:%@@*****+==+#@@@%%%@: .-%%%%%%%=..:---******#@@*-:....+%@@@%-:-%#----------------#==#:..+@@@%-....#@%*****+==*@@@##%%%*. .-%%%%+*%%=..:---*******%@*:.......*@@@@@%--%*---------------==@@#-+%@@@@@-....@@*****+===#@@%#*%%%%. .%%%#=.=%%%=..:--=******#@@*....**==%@@@@@@%:+*----------------*@@@@@@@@@@@-....@%******+==#@@@##+%@%+ .%%%#..-#@@@=..:--+******#%@*...:*@@@@@@@@@@%==#-------++++++=--=@@@@@@@@@@@-...=@%******+==+%@@##*%@@% *%%#.:+*%@@#-.:---+*******#@*:...+@@@@@@@@@@%-#=---=*#*+=====*%*=-%@@@@@@@%-...=@%******+====#@@%##%@@% %%%+.++#%@@*-::----********#@#-...-#@@@@@@%=:*+:::=#%#*+++++*#%%%-:=+*#+=-...+%@#******+-:===#@@@#*@@@% %%%+=+##%@@#-------=*********%%*-...:------**-:...=@@%%%%%%@@@@@@#..:=++**#%##********+:..-==#@@@#%@@@: %%%%++**#@@#=------.:**********#%%#####%*#*-:.....+@@@@@@@@@@@@@%-:....:=**********+:.....-==#@@%%%@%: -@@@#+**#@@@+-----:...:*****************-:.........:*@@@@@@@@@@#=-:::....................:-=*%@@@@@%. =@@@%+*#@@@+-----:.......-*+++****=:.................-#@@@@*=--::::=*...................:-=#@@@@+. .%@@@#*%@@%=-----........................:@@#............:-::-:-=%@*:::...............:==*@@@- .-%@@@@@@@*-----.........................:*%@#-:......:::::-=+@@#=::::::............:==+#@@*: .=##%@@@+-----...........................-#%@@@%*===+#@@@%#*::::::::::.........:-==+%@@#: +%@@*-----:.............................:+#******#-:::::::::::::::.......-===*@@@*: =%@@#=-----:...................................::::::::::::::::::....:-====#@@%=. -#@@@*------:................................:::::::::::::::::::::-====+#@@@#: .-%@@@#-------::......................:::::::::::::::::::::::--=====#%@@@#- =+*+. .-%@@@%#==-------::.........::::::::::::::::::::::::::---=====-#%@@@@#: +%@@%@@* .:%@@@@%#+========------::::::::::::::::::::------======-*%@@@@@#:. .#@=--*@%- .:*@@@@@@%#+--==--------------------------------====#@@@@@+:. :#@=---@@= ..:#@@@@@@@@%=------------------------------=-===#@@@: :#@---=@@= .+%@@@=-----------------------------------*%@@: .#@%*-@@@@@@%+. -#@@=------------------------------------#@@+. .*%%%*%@@=-=%=%@#- .*%%-..--------------------:::::::-------#@@@: -#@=----=@%=*%=+#@*: *%%#..----------------:..............:--+%@@:. +@@+==#%@@*#@#+*%@*: +%%%:..-------------:..................:-#@@@%==: ..-+%%%*+++###%@@@%@@%- :#%%:..:----------:......................*@@@@@@%%%%%%%%%%*::=++++++*#@@%+. .=#%%%*-...:--------:........................#@@#+++*###*==:..:---===+++*@@#. -#%%%#=...::---------:.........................=%@@*--::::::::---------==+@@%+ .-#%%%+:..::------------:..........................#@@@=-----------------===@@@*. :#%%#=...::--------------:..........................-%@@%=-------------====#@@@*. .#%%%+...:-----------------:..........................:+@@@+==---------====+%%@%: .%%%*...:::-----------------:...........................-%@@%====--=======+%@@@% .%%%*..:--------------=%=----:...........................--@@@#++++=====+#@@@@#. %%%#..::------------+%%+-----:...........................:-%@@%+++++**%@@@@#+. .%%#:..::----------#@@%*=-----:............................-=@@@@@@@@@@@%#+. =%%#..-:----------%@@@=+=-----:............................-=#@@@@%#*+=. :%%%-------------=+%@@#=------:............................-==@@%+ %%%#-------------=+%@@#------:............................:==@@@# .@@%#=------------=+@@%+-----:.............................==*@@#: .#@@%*=---------==++@@%-----:.............................===@@@* .-%@@@%%%%%%%*++++*@@%-----:.............................===%@@* ..=@@@@@*+++++++@@%=-----:.............................-==+@@#- =@@#+++++++%@@@+=----:..............................:==+@@@* =@@#*++%@@@@@%===----:..............................:===%@@* .=%@@@@@@%%++===-----:..............................:===*@@#: .:=+%%@#*++--------:::..........:::-----+%@@#+++++===-----:...............................:===+@@@+ :+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@@#+=====--------:..............................::===+@@@* :#%%%******++++++**#######*++++=++++++++*#%%%=.:-------------:............................::::===+%@@* -%%%#*********************-:::::::::::::=#%%+..:-------------:..........................::::::===+#@@*. .+%%%#*******************=--------------*%%*:.::-------------:........................::::::::====+@@#: .=%@@@%#****************=-------------+@@#=..:--------------:......................:::::::::-====+@@#- .-#@@@%%*********####*=============+@@@+..:---------------:....................::::::::::-=====+@@@+ ..+%@@@@@@#***#####+=============#@@*..:-----------------:................::::::::::::-======+@@@+ .#@@@@@@@@%###+============+@@@*.:-------------------:............::::::::::::::========+@@@+ .*%%@@@@@@@@@@%*=++++++#@@*..:---------------------:.......::::::::::::::--=====+===+@@@+ .:*##%@@@@@@@@@@@@@@@*.:-------------------------::::::::::::::::--============+@@@+ .:=+***##@@@*.:-----------------------======---------===========+=+===+@@@+ :@@%+:--------------------================================+===+@@@+ :@@%=------------------====================++++++++++++++=====+@@@+ .:@@#=---------------==========*###%@@@@@@@@@@@@%%##*+==+++====+@@%= .-@@%=------------=========*#%@@@@@@@@##@@@@@@@@@@%##*+++++++++*@@#- :@@%+----------========+#%@@@#=-... :@@@%++++++++++++++++++*@@#: =#%%@@%*-------=========+#@@@*-. .-@@@#=++++++++++++++++*@@@@%%*: =%%@@#:::------==========+%@@#:. .=@@%*+++++++++++++++++=-=*%@@@%- #@@@....-----============+@@@# .*@@#++++++++++++++++++++=--*@@@: .:%@@@@@@@@@@@@@@@@@@@@@@@@@@@# :%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#: .+############################: :##############################:. ``` ### Create the plugin and load it Now, let's create the plugin. Please create `plugins/ascii-injector/index.mjs` and copy/paste the source code here below to your file: **plugins/ascii-injector/index.mjs** ```mjs /** * Docusaurus Plugin: ascii-injector * * Purpose: * This plugin will prepend an ASCII art banner (from a specified text file) * in all generated HTML files as an HTML comment. * * Usage: * - Save this plugin as `plugins/ascii-injector/index.mjs` * - Create the file containing the ASCII art in `src/data/banner.txt`. * - Add the plugin to `docusaurus.config.js` plugins array. * * Please see readme.md for more details */ import { promises as fs } from "node:fs"; import path from "node:path"; /** * Recursively collect all HTML files in a given directory. */ async function collectHtmlFiles(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const files = await Promise.all( entries.map(async (entry) => { const fullPath = path.resolve(dir, entry.name); if (entry.isDirectory()) return collectHtmlFiles(fullPath); if (entry.isFile() && entry.name.endsWith(".html")) return [fullPath]; return []; }), ); return files.flat(); } /** * Docusaurus plugin that injects an ASCII art banner into all generated HTML files. * * @param {object} context - Docusaurus plugin context (unused). * @param {object} options - Plugin configuration options. * @param {string} options.bannerPath - Relative path (from siteDir) to the ASCII banner file. */ export default function asciiInjectorPlugin(context, options) { if (!options || typeof options.bannerPath !== "string") { throw new Error("[ascii-injector] Missing required option: bannerPath (string)"); } return { name: "ascii-injector-plugin", async postBuild({ outDir, siteDir }) { const bannerPath = path.resolve(siteDir, options.bannerPath); let rawBanner; try { rawBanner = await fs.readFile(bannerPath, "utf8"); } catch (err) { console.error(`[ascii-injector] Banner file missing: ${err.message}`); return; } const commentBanner = `\n\n`; const htmlFiles = await collectHtmlFiles(outDir); console.log( `[ascii-injector] Preparing to inject banner into ${htmlFiles.length} HTML files...`, ); const results = await Promise.allSettled( htmlFiles.map(async (filePath) => { try { const html = await fs.readFile(filePath, "utf8"); // proceed to inject; files are always regenerated during build let updated; // Default: insert comment after const doctype = /]*>/i; if (doctype.test(html)) { updated = html.replace(doctype, (m) => `${m}\n${commentBanner}`); } else { // fallback to prepending comment updated = commentBanner + html; } // Write atomically: temp file + rename avoids race conditions // with other postBuild plugins that read HTML concurrently. const tmpPath = `${filePath}.tmp`; await fs.writeFile(tmpPath, updated, "utf8"); await fs.rename(tmpPath, filePath); return { status: "ok", filePath }; } catch (err) { return { status: "error", filePath, error: err }; } }), ); const ok = results.filter( (r) => r.status === "fulfilled" && r.value && r.value.status === "ok", ).length; const skipped = results.filter( (r) => r.status === "fulfilled" && r.value && r.value.status === "skipped", ).length; const failed = results.filter( (r) => r.status === "fulfilled" && r.value && r.value.status === "error", ).length + results.filter((r) => r.status === "rejected").length; console.log( `[ascii-injector] Injection complete — ok: ${ok}, skipped: ${skipped}, failed: ${failed}`, ); }, }; } ``` Almost done. Now, finalize the installation by editing your `docusaurus.config.js` file and, under `plugins`, please do this: **docusaurus.config.js** ```javascript // [...] plugins: [ // [...] [ "./plugins/ascii-injector/index.mjs", { bannerPath: "src/data/banner.txt" }, ], ], ``` The very last thing to do is to restart your Docusaurus website to load the updated configuration file. ## More Demos ### Time to test Nothing new here, just generate the static version of your site. ```bash $ yarn docusaurus build $ yarn docusaurus serve ``` Once your website is generated, just open any page and press `CTRL`+`U` and tadaaa, your ASCII art is there — exactly the result shown at the top of this article. ## Under the Hood (skip this if you just want to use it) > **Not during preview mode:** > > This plugin is only fired during the `postBuild` event i.e. only after your blog has been rendered as an HTML page. So if you're running Docusaurus in preview mode; you'll not see the ASCII art. ## Conclusion A `postBuild` plugin, one ASCII art file, and a three-line config addition — and every page your site generates carries a small, pointless, entirely intentional signature in its source. If the command-line side of generating ASCII banners interests you more than the Docusaurus plumbing, [Bash - ASCII art](/blog/bash-ascii-art) covers that half. --- # Best Practice - Customizing the Docusaurus RSS Feed for Full Content & Images > Upgrade your Docusaurus blog's RSS file. Write a plugin to disable the basic feed and include full article content, images and enhanced metadata for better readers. --- ![Best Practice: Customizing the Docusaurus RSS Feed for Full Content & Images](/img/v2/docusaurus_rss_enhanced.webp) **TL;DR** Docusaurus's default RSS feed is basic, lacking full article content and images, which limits its utility for RSS readers. This article provides a solution by guiding users to create a custom plugin that disables the default feed generation. The new plugin produces an enhanced `rss.xml` file, incorporating full article HTML content, images, and improved metadata for a richer reader experience. If you're running Docusaurus, you can ask it to generate a `rss.xml` file by adding the code below in the `blog` section of `docusaurus.config.js`. ```javascript feedOptions: { type: ["rss"], }, ``` Docusaurus uses **RSS 2.0** and exports, by default, up to 20 articles. The file generated is relatively basic; for example, it does not include the image from the post or the HTML content of the article. Docusaurus creates a fairly simple file. Let's see how to enhance it. *This is another local Docusaurus plugin, like the ones built in [Creating a search\&replace plugin for Docusaurus](/blog/docusaurus-plugin-replace) and [Inject ASCII Art in any HTML pages rendered by Docusaurus](/blog/docusaurus-ascii-art) — this one hooks the `postBuild` event too.* ## What is the rss.xml file? This file is used by RSS readers to be informed of new articles on your blog. Some people, myself included, use these tools for technical monitoring (or tech watch): they use services like [Feedly](https://feedly.com/) or [inoreader](https://www.inoreader.com/) to subscribe to new content published on the sites they want to track. As a result, when they open their RSS reader, they instantly see a list of updates, all powered by the RSS feed. The standard `rss.xml` file generated by Docusaurus is too basic for me. Indeed, when I open my RSS reader, I'm missing the full content of my article and the introduction image. If you're maintaining a blog with Docusaurus, your RSS is located at the `blog/rss.xml` location. For my site, it's . It's an XML file but you'll see a nice HTML page because there is an `XSLT` (a transformation of XML to HTML). ![The default rss.xml file with Docusaurus](./images/default_rss.webp) > **View source:** > > Just press `CTRL`+`U` to see the XML source code if you're interested. ## Viewing the RSS using Inoreader Below, what I was seeing in Inoreader: ![What I was seeing in Inoreader](./images/before_inoreader.webp) In the next chapters, we'll see how to improve our `rss.xml`. Once done, you'll get this: ![After our optimization](./images/after_inoreader.webp) And by opening an article, we'll get this: ![Displaying an article](./images/article_inoreader.webp) ## How Docusaurus is managing feeds The generation of feeds is defined by the `feedOptions` item in the `docusaurus.config.js` file. I've tried the official documentation () but **didn't succeed in managing the `createFeedItems` element**: we're supposed to be able to define which information (author, categories, description, content, images, ...) we want in the feed but, I have to admit, all my attempts have failed. That's why, after hours, I've created my own plugin using AI. ## Let's optimize it ### Prepare your system Please start a console and add some dependencies needed by the plugin we'll create. ```bash $ yarn add cheerio feed front-matter fs-extra glob ``` ### Create the plugin Please create the `plugins/blog-feed-plugin/index.js` with the code below **plugins/blog-feed-plugin/index.js** ```javascript /** * @fileoverview Docusaurus Blog Feed Plugin (RSS 2.0) * * Generates an optimized RSS feed (blog/rss.xml) from the final HTML in the Docusaurus build output. * Extracts and cleans article content to avoid leaking UI elements, respects front matter metadata, * and includes optional image support with MIME type detection. * * Key features: * - Robust article extraction with selector fallbacks. * - Critical selectors always stripped (header, svg). * - Configurable cleaning via options.stripSelectors (user-defined). * - Properly normalized slugs and POSIX-safe permalinks. * - Absolute URLs for links and images (siteConfig.url + baseUrl + paths). * - Optional image injection to descriptions and enclosure tags. * - Safe character cleanup to prevent invalid XML characters. * * Options: * - maxItems?: number (default: 20) — items in the site-wide feed * - includeContent?: boolean (default: true) * - includeImages?: boolean (default: true) * - stripSelectors?: string[] (default: curated list, merged with critical) * - ignorePatterns?: string[] * - emitTopicFeeds?: boolean (default: true) — per-tag and per-series feeds * - topicMaxItems?: number (default: 10) — items in each per-topic feed * - minPostsPerTagFeed?: number (default: 1) — a tag below this gets no feed * * Usage: * const blogFeedPlugin = require("./plugins/blog-feed-plugin/index.js"); * * module.exports = { * // ... * plugins: [ * [blogFeedPlugin, { * maxItems: 20, * includeContent: true, * includeImages: true, * stripSelectors: [ * ".custom-ads", * ".share-buttons" * ], * }], * ], * }; * * Output: * - The RSS file is written to: /blog/rss.xml (served at /blog/rss.xml). * - Per-tag feeds: /blog/tags//rss.xml (served at /blog/tags//rss.xml). * - Per-series feeds: /series//rss.xml (served at /series//rss.xml). * * @license MIT — free to use, modify, and contribute. */ const yaml = require("js-yaml"); const fs = require("fs-extra"); const path = require("path"); const frontMatter = require("front-matter"); const glob = require("glob"); const cheerio = require("cheerio"); // Which posts land in which per-topic feed. Split out because it is pure and // self-contained; this file keeps the serializing and the writing. const { groupItemsByTag, groupItemsBySeries } = require("./topic-feeds.cjs"); // --- Utilities --------------------------------------------------------------- /** * Join URL segments with POSIX semantics and ensure leading slash consistency. * @param {string[]} parts * @returns {string} */ function posixJoin(...parts) { const joined = path.posix.join(...parts); return joined.startsWith("/") ? joined : `/${joined}`; } /** * Resolve an absolute URL from site config components. * @param {string} siteUrl * @param {string} baseUrl * @param {string} relPath * @returns {string} */ function absoluteUrl(siteUrl, baseUrl, relPath) { const base = baseUrl || "/"; const rel = relPath || "/"; const fullPath = posixJoin(base, rel); return `${siteUrl.replace(/\/+$/g, "")}${fullPath}`; } /** * Determine the MIME type from an image URL. * @param {string} url * @returns {string} */ function getMimeTypeFromUrl(url) { const ext = path.extname(url).toLowerCase(); switch (ext) { case ".jpg": case ".jpeg": return "image/jpeg"; case ".png": case ".avif": return "image/png"; case ".gif": return "image/gif"; case ".webp": return "image/webp"; case ".svg": return "image/svg+xml"; default: return "application/octet-stream"; } } /** * Remove control/non-printable characters that can break XML parsers. * @param {string} s * @returns {string} */ function cleanProblemChars(s) { return ( String(s) .replace(/\u00a0/g, " ") .replace(/\u200b/g, "") // eslint-disable-next-line no-control-regex .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g, "") .trim() ); } // --- Strip selectors --------------------------------------------------------- const CRITICAL_STRIP_SELECTORS = ["header", "svg"]; const DEFAULT_STRIP_SELECTORS = [ 'h3:contains("Related posts")', "div.row:has(div.col.col--4)", "footer.docusaurus-mt-lg", ".blueSkyContainer", 'p:contains("Loading comments")', ".scrollBtn_Pv66", ]; function getStripSelectors(userSelectors = []) { return [...CRITICAL_STRIP_SELECTORS, ...DEFAULT_STRIP_SELECTORS, ...userSelectors]; } // --- Article extraction ------------------------------------------------------ /** * Locates a built page from its permalink. * * `outDir` is ALREADY the locale's output directory (`build/` for the default locale, * `build/fr/` for `fr`), while `permalink` already carries the locale's baseUrl * (`/fr/blog/`). Joining them naively produces `build/fr/fr/blog/` — a path that * never exists, so every article silently lost its body and the French feed came out empty * with a green build. Strip the baseUrl prefix before joining. See TODO 0119. */ async function getArticleHtml(permalink, outDir, stripSelectors = [], baseUrl = "/") { const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; const relativePermalink = normalizedBase !== "/" && permalink.startsWith(normalizedBase) ? permalink.slice(normalizedBase.length - 1) : permalink; const htmlFilePath = path.join(outDir, relativePermalink, "index.html"); if (!fs.existsSync(htmlFilePath)) { console.warn( `[BlogFeedPlugin] HTML file not found for ${permalink} at: ${htmlFilePath}`, ); return null; } const htmlContent = await fs.readFile(htmlFilePath, "utf8"); const $ = cheerio.load(htmlContent); // Attempt to find the main article container using multiple fallbacks let articleContainer = $("article").first(); if (!articleContainer.length) { articleContainer = $(".theme-doc-content, .theme-doc-markdown").first(); } if (!articleContainer.length) { console.warn(`[BlogFeedPlugin] Could not find article container for ${permalink}.`); return null; } // Remove elements based on strip selectors const selectorsToRemove = getStripSelectors(stripSelectors); selectorsToRemove.forEach((sel) => { articleContainer.find(sel).remove(); }); // Handle Docusaurus code snippet wrapper cleanup for cleaner RSS content articleContainer.find(".snippet_block_pySp").each((_, element) => { const $snippet = $(element); const $codeBlock = $snippet.find(".codeBlockContainer_Ckt0").first(); if ($codeBlock.length) { $snippet.replaceWith($codeBlock); } else { $snippet.remove(); } }); const contentHtml = articleContainer.html(); if (!contentHtml) return null; return cleanProblemChars(contentHtml); } // --- Slug computation -------------------------------------------------------- function computeSlug(attributes, relativeFilePath) { if (attributes.slug) { const normalized = String(attributes.slug).replace(/^\/|\/$/g, ""); return normalized || null; } const slugWithDate = relativeFilePath.replace(/\.(md|mdx)$/i, ""); const parts = slugWithDate.split(path.sep); let finalSlug; // Handle index file inside a folder (e.g., folder/index.md) if (parts[parts.length - 1].toLowerCase() === "index" && parts.length > 1) { finalSlug = parts[parts.length - 2]; } else { finalSlug = parts[parts.length - 1]; } if (!finalSlug) return null; // Normalize slug to lowercase and use hyphens return finalSlug.toLowerCase().replace(/_/g, "-"); } // --- Feed assembly ----------------------------------------------------------- /** * Serialize one RSS 2.0 document — channel header, items, dc:creator injection * and the XSLT processing instruction. * * Extracted from `postBuild` so the same recipe produces the site-wide feed and * every per-topic one. The cheerio pass has to live in here rather than at the * call site: it runs on the serialized XML, so a feed built outside it would * silently ship without its `` elements. * * @param {object} params * @param {Function} params.Feed - the `feed` package's Feed constructor. * @param {object} params.channel - `{title, description, id, link, language}`. * @param {Array} params.items - items to serialize, newest first. * @param {string} params.siteUrl - site origin, no trailing slash. * @param {string} params.baseUrl - Docusaurus baseUrl. * @param {object} params.authorsData - parsed `blog/authors.yml`. * @param {boolean} params.includeContent - emit `` bodies. * @param {boolean} params.includeImages - emit `` and inline ``. * @param {boolean} [params.verbose] - per-item logging (site-wide feed only). * @returns {string} the complete RSS 2.0 document. */ function buildRssXml({ Feed, channel, items, siteUrl, baseUrl, authorsData, includeContent, includeImages, verbose = false, }) { const feed = new Feed({ title: channel.title, description: channel.description, id: channel.id, link: channel.link, language: channel.language, // Drives . Deliberately the newest post's date rather // than `new Date()`: a wall-clock value rewrote this 1.3 MB file on // every build, so the deploy re-uploaded it even when no article had // changed. The date of the most recent entry is also the more honest // answer to "when did this feed last actually change?". Per-topic feeds // take *their own* newest entry for the same reason — a shared clock // would dirty all of them on every build. updated: items.length ? new Date(items[0].date) : new Date(0), }); // Parallel to `items`: the dc:creator markup each entry needs, consumed by the // cheerio pass below. It used to be smuggled through `feed.addItem()` as a // `customRssData` key and read back off the *source* item — which never // carried it, so the injection silently no-opped and no feed this plugin ever // wrote had a single in it. Keeping it here, where it is // produced, is what makes the feature actually work. const creatorMarkup = []; items.forEach((item) => { // Resolve authors const authorKeys = Array.isArray(item.frontMatter.authors) ? item.frontMatter.authors : item.frontMatter.authors ? [item.frontMatter.authors] : []; const rssAuthors = authorKeys .map((key) => authorsData[key]) .filter(Boolean) .map((a) => ({ name: a.name })); creatorMarkup.push( rssAuthors .map((a) => ``) .join(""), ); // Log to confirm data is ready if (verbose && rssAuthors.length > 0) { console.log( `[BlogFeedPlugin] ✍️ Post "${ item.title }" - Found authors: ${rssAuthors.map((a) => a.name).join(", ")}`, ); } // Handle enclosure image and description injection const postImageUrl = item.frontMatter?.image; const absoluteImageUrl = postImageUrl ? absoluteUrl(siteUrl, baseUrl, postImageUrl) : null; const mimeType = absoluteImageUrl ? getMimeTypeFromUrl(absoluteImageUrl) : null; let descriptionWithImage = cleanProblemChars(item.description || ""); if (includeImages && absoluteImageUrl) { descriptionWithImage = `${cleanProblemChars(
          item.title,
        )}` + descriptionWithImage; } const itemLink = absoluteUrl(siteUrl, baseUrl, item.permalink); feed.addItem({ title: cleanProblemChars(item.title), id: item.permalink, link: itemLink, category: Array.isArray(item.frontMatter.tags) ? item.frontMatter.tags.map((t) => ({ name: t })) : item.frontMatter.tags ? [{ name: item.frontMatter.tags }] : undefined, description: descriptionWithImage, content: includeContent ? cleanProblemChars(item.fullContentBody || "") : undefined, date: new Date(item.date), author: rssAuthors, // Add enclosure tag if image is present ...(includeImages && absoluteImageUrl && mimeType && { enclosure: { url: absoluteImageUrl, type: mimeType, length: 0, // Length is often 0 for dynamically generated feeds }, }), }); }); let rssContent = feed.rss2(); // --- CUSTOM XML MANIPULATION (Cheerio) --- // The 'feed' library does not natively support dc:creator with CData, // so we use Cheerio for post-generation injection. // 1. Load the content const $ = cheerio.load(rssContent, { xmlMode: true }); // 2. Inject the namespace cleanly into the root element $("rss").attr("xmlns:dc", "http://purl.org/dc/elements/1.1/"); // 3. Get ALL generated XML items // In XML mode, the path is typically rss > channel > item const xmlItems = $("channel > item"); if (verbose) { console.log(`[BlogFeedPlugin] 🔧 Injecting DC tags into ${xmlItems.length} items...`); } // 4. Inject each item's dc:creator. Positional matching is safe: the feed // library serializes items in the order they were added, which is the // order of `items` — and so of `creatorMarkup`. xmlItems.each((index, element) => { const markup = creatorMarkup[index]; if (markup) $(element).append(markup); }); // 5. Get the final XML content rssContent = $.xml(); // Apply a custom XML declaration and stylesheet reference for better browser // rendering. The href is ABSOLUTE on purpose: a relative "rss.xsl" only // resolves for the feed sitting at /blog/, and would 404 for every per-topic // feed one directory deeper — the browser then shows raw XML. return rssContent.replace( /^<\?xml[^>]+\?>/, `\n`, ); } // --- Plugin ------------------------------------------------------------------ module.exports = function blogFeedPlugin(context, options = {}) { const { maxItems = 20, includeContent = true, includeImages = true, stripSelectors = [], ignorePatterns = ["**/_archived/**"], emitTopicFeeds = true, topicMaxItems = 10, minPostsPerTagFeed = 1, } = options; return { name: "blog-feed-plugin", async postBuild({ siteConfig, outDir, siteDir, i18n }) { try { // AJOUTEZ CETTE LIGNE ICI 👇 const { Feed } = await import("feed"); console.log(`[BlogFeedPlugin] 🚀 Generating RSS feed...`); const blogDir = path.join(siteDir, "blog"); const baseUrl = siteConfig.baseUrl || "/"; const siteUrl = siteConfig.url?.replace(/\/+$/g, "") || ""; const blogBasePath = posixJoin(baseUrl, "blog"); if (!siteUrl) { console.error( `[BlogFeedPlugin] siteConfig.url is missing. Please set it to your site's origin.`, ); return; } if (!fs.existsSync(blogDir)) { console.error(`[BlogFeedPlugin] Blog source directory not found: ${blogDir}`); return; } // --- Load Authors --- const authorsFile = path.join(siteDir, "blog", "authors.yml"); let authorsData = {}; if (fs.existsSync(authorsFile)) { authorsData = yaml.load(fs.readFileSync(authorsFile, "utf8")); console.log(`[BlogFeedPlugin] Loaded authors from ${authorsFile}`); } // --- Load Tags --- // Only for the human-readable label and description of a per-tag feed's // channel; the grouping itself works off the posts' own front matter. const tagsFile = path.join(siteDir, "blog", "tags.yml"); let tagsData = {}; if (fs.existsSync(tagsFile)) { tagsData = yaml.load(fs.readFileSync(tagsFile, "utf8")) || {}; } const postFiles = glob.sync("**/*.{md,mdx}", { cwd: blogDir, ignore: ignorePatterns, }); // Collect raw metadata from blog files const rawMetadataItems = await Promise.all( postFiles.map(async (relativeFilePath) => { const fullPath = path.join(blogDir, relativeFilePath); const fileContent = await fs.readFile(fullPath, "utf8"); const contentData = frontMatter(fileContent); const { attributes } = contentData; // Drafts and unlisted posts get no HTML page in the build, so they // can never feed the RSS body — skip them before we go looking for // a file that is not there. if (attributes.draft === true || attributes.unlisted === true) { return null; } const date = attributes.date || fs.statSync(fullPath).birthtime; const finalSlug = computeSlug(attributes, relativeFilePath); // Ignore files that map to "index" or have no slug if (!finalSlug || finalSlug === "index") return null; const permalink = posixJoin(blogBasePath, finalSlug); return { title: attributes.title || finalSlug, description: attributes.description || "No description", date, permalink, frontMatter: attributes, finalSlug, }; }), ); const metadataItems = rawMetadataItems.filter(Boolean); if (!metadataItems.length) { console.log(`[BlogFeedPlugin] No blog posts found.`); return; } // Collect full HTML content for each post (required for RSS body) const feedItemsWithContent = await Promise.all( metadataItems.map(async (item) => { const fullContentBody = await getArticleHtml( item.permalink, outDir, stripSelectors, baseUrl, ); return { ...item, fullContentBody }; }), ); // Filter out drafts (published: false) and articles with no content body let publishedFeedItems = feedItemsWithContent .filter((item) => item.frontMatter.published !== false && item.fullContentBody) .sort((a, b) => new Date(b.date) - new Date(a.date)); // In a non-default locale, only articles that really are translated belong in the feed. // Docusaurus's i18n fallback means every English article also has a `/fr/` URL, so // without this the French feed would advertise 257 English posts as French content — // the same duplicate-content problem plugins/i18n-seo-guard solves for the HTML pages. // See TODO 0119. const currentLocale = i18n?.currentLocale ?? siteConfig.i18n?.defaultLocale; if (currentLocale && currentLocale !== siteConfig.i18n?.defaultLocale) { const { collectTranslations, } = require("../translations-manifest-plugin/index.cjs"); const translated = new Set(collectTranslations(siteDir)[currentLocale] ?? []); // `finalSlug` is the item's own slug, already computed upstream — use it rather than // re-deriving one from the permalink, which silently matched nothing and emptied the // whole feed on the first attempt. publishedFeedItems = publishedFeedItems.filter((item) => translated.has(item.finalSlug), ); console.log( `[BlogFeedPlugin] locale ${currentLocale}: ${publishedFeedItems.length} translated article(s) in the feed.`, ); } const language = currentLocale || siteConfig.i18n?.defaultLocale || "en-US"; // --- Site-wide feed -------------------------------------------------- const finalFeedItems = publishedFeedItems.slice(0, Math.max(1, Number(maxItems))); const rssPath = path.join(outDir, "blog", "rss.xml"); await fs.ensureDir(path.dirname(rssPath)); await fs.writeFile( rssPath, buildRssXml({ Feed, channel: { title: siteConfig.title, description: siteConfig.tagline || "Personal blog feed", id: siteUrl, link: absoluteUrl(siteUrl, baseUrl, "blog"), language, }, items: finalFeedItems, siteUrl, baseUrl, authorsData, includeContent, includeImages, verbose: true, }), ); console.log( `[BlogFeedPlugin] ✅ RSS feed written to ${rssPath} (${finalFeedItems.length} items).`, ); if (!emitTopicFeeds) return; // --- Per-topic feeds ------------------------------------------------- // // One feed per tag and per series, so a reader can watch a single subject // instead of the whole blog. Two deliberate differences from the // site-wide feed: // // * no . The site-wide feed weighs ~45 KB per item // because it carries every article's full HTML; replaying that // across ~75 topic feeds would add tens of megabytes of duplicated // content to build/. Descriptions only keeps each one in atom.xml // territory (tens of KB), and a topic feed's job is to announce a // post, not to replace it. // * a lower item count (`topicMaxItems`), for the same reason. // // The expensive part — reading and cheerio-parsing every post's built // HTML — already happened above for the site-wide feed, so these cost // serialization and I/O only. const topicFeeds = []; for (const group of groupItemsByTag(publishedFeedItems).values()) { // Raising this above 1 strands the article action bar: it offers the // feed of the post's own mainTag without knowing how many posts share // it, so a skipped tag becomes a 404 for the reader who clicks. Only // raise it together with a way for the UI to know which feeds exist. // // Thin feeds are cheap and not useless anyway: a subscriber signs up // for what comes next, not for the archive depth, and 49 description- // only tag feeds weigh about as much as one copy of the site-wide one. if (group.items.length < minPostsPerTagFeed) continue; const meta = tagsData[group.label] || {}; topicFeeds.push({ outPath: path.join(outDir, "blog", "tags", group.slug, "rss.xml"), channel: { title: `${siteConfig.title} — ${meta.label || group.label}`, description: meta.description || `Posts tagged “${meta.label || group.label}” on ${siteConfig.title}`, id: absoluteUrl(siteUrl, baseUrl, `blog/tags/${group.slug}`), link: absoluteUrl(siteUrl, baseUrl, `blog/tags/${group.slug}`), language, }, items: group.items, }); } for (const group of groupItemsBySeries(publishedFeedItems).values()) { // No threshold for series: a series is by definition a sequence, and // "tell me when the next episode lands" is the strongest reason to // subscribe this blog has to offer. topicFeeds.push({ // Next to the page it belongs to — the series route is /series/, // not /blog/series/. outPath: path.join(outDir, "series", group.slug, "rss.xml"), channel: { title: `${siteConfig.title} — ${group.label}`, description: `Every post in the “${group.label}” series on ${siteConfig.title}`, id: absoluteUrl(siteUrl, baseUrl, `series/${group.slug}`), link: absoluteUrl(siteUrl, baseUrl, `series/${group.slug}`), language, }, items: group.items, }); } await Promise.all( topicFeeds.map(async ({ outPath, channel, items }) => { const feedItems = items.slice(0, Math.max(1, Number(topicMaxItems))); await fs.ensureDir(path.dirname(outPath)); await fs.writeFile( outPath, buildRssXml({ Feed, channel, items: feedItems, siteUrl, baseUrl, authorsData, includeContent: false, includeImages, }), ); }), ); console.log( `[BlogFeedPlugin] ✅ ${topicFeeds.length} per-topic feeds written (tags + series).`, ); } catch (err) { console.error(`[BlogFeedPlugin] RSS feed generation failed:`, err); } }, }; }; ``` ### Update your configuration Please update your `docusaurus.config.js` and do two things. 1. We need to disable the RSS generation of the feed by Docusaurus 2. We need to implement our own plugin See below how to do: **docusaurus.config.js** ```javascript // [...] presets: [ [ "classic", ({ blog: { // ... feedOptions: { type: ["atom"], }, }), ], ], plugins: [ // [...] [ "./plugins/blog-feed-plugin/index.js", { maxItems: 20 }, ], ], ``` > **atom can stay:** > > In `feedOptions` you can keep `type: ["atom"]` if you've it but please remove `rss` if you see it. > > There are two types of feeds, the older one (**rss**) and the newer (**atom**). ### Run the plugin Once done, just run `yarn run build` in a terminal to generate the static version of your site. ```bash $ yarn run build ``` During this stage, Docusaurus will call our plugin to generate the `rss.xml` file instead of calling its own, standard, feature. Once finished, if you want to see the file in action on your machine, just run `yarn run serve` to start your Docusaurus static website then go to the `http://localhost/blog/rss.xml` (your local URL) and you'll see your XML. ### Visualize the new look\&feel Let's use an online tool like . In the left part, just copy/paste the content of your `blog/rss.xml` and look how it'll be displayed; nice no? ![The rss.xml using RSS 2.0 and viewed using https://codebeautify.org/rssviewer](./images/using_codebeautify.webp) You can use another tool like but here, you must first upload your `blog/rss.xml` file to your production site first. ![The rss.xml using RSS 2.0 and viewed using https://rss.app/rss-feed/](./images/using_rss_app.webp) ## Why is this better? As you can see, the new `rss.xml` file will now expose the introduction image but not only, you'll also have much more information like the HTML of your article. > **View source:** > > Remember, just press `CTRL`+`U` to see the XML source code. RSS aggregators, even the older ones, will have access to the full content so they can display the article without having to grab it from your website. ## Configuration options of the plugin The plugin can be configured directly in your `docusaurus.config.js` file. See below all optional options you can use: ```javascript const config = { // ... plugins: [ [ "./plugins/blog-feed-plugin/index.js", { maxItems: 20, // optional includeContent: true, // optional includeImages: true, // optional stripSelectors: [ // optional — add any site-specific UI selectors to remove ".custom-ads", ".share-buttons", ".newsletter-signup", ], ignorePatterns: ["**/_archived/**"], // optional }], ], }; export default config; ``` * **maxItems** (number, default: `20`): Limit the number of items in the feed. By default, Docusaurus is only showing 20. The higher this number is, the larger the rss.xml file will be. You might be tempted to want all of the articles (in my case, nearly 215 at present), but that is not the primary purpose of an RSS file. This file is there to list the **latest articles** and allow you to see the most recent posts on your blog. So be... reasonable. * **includeContent** (boolean, default: `true`): Include the cleaned HTML body of each post in the `` field. If you want to force people to visit your website to get the full content, just initialize this option to `false`. * **includeImages** (boolean, default: `true`): Add the post image at the top of the description and include an `` tag with proper MIME type. * **stripSelectors** (string\[], default: merged with defaults): Additional CSS selectors to remove from the article HTML. Critical selectors (`header`, `svg`) are always removed. * **ignorePatterns** (string\[], default: \[`"**/_archived/**"`]): Glob patterns for excluding certain blog files. ## Some tools ### Visualization tools While you're coding the plugin, just use some of these visualization tools to make sure the content is correctly displayed. * * * ### Validation tools If you want to make sure your `rss.xml` is valid: * * * --- # Running some checks on your Docusaurus images > A Python script to verify lazy loading and size of images in your Docusaurus site using Playwright and BeautifulSoup. --- ![Running some checks on your Docusaurus images](/img/v2/check_images.webp) **TL;DR** This article explains how to enforce lazy loading on a Docusaurus blog by overriding the default MDX image component to automatically inject the loading attribute. It further details how to validate this behavior using a containerized Python and Playwright script that simulates user scrolling to check image attributes and file sizes. This workflow ensures that performance best practices are consistently applied and verified across all blog content. Like you know me well enough by now, I care a lot about performance. For a lot of good reasons, but mainly because it's fun to optimize things and see the results immediately. And it's a good challenge too! My blog is now quite big (more than 200 articles), and I wanted to make sure that all images are properly lazy-loaded — but how can I be sure of that? Lazy load? What is it? When you lazy load an image, you tell the browser to only load it when it's about to enter the viewport (i.e., when the user scrolls near it). So instead of loading all images at once when the page loads, only the images that are immediately visible are loaded first. This reduces the initial load time and saves bandwidth, especially for pages with many images. In this post, I'll share how I enforced lazy loading on this Docusaurus blog and, more importantly, how I automated the verification of this behavior using a Python script. ## Running the Check Once the script (shown further down) and its container are in place, auditing every image on the site was one command: ```bash $ docker run -it --rm -v .:/app -w /app --entrypoint /bin/sh mcr.microsoft.com/playwright/python:v1.61.0-jammy -c "pip install --root-user-action=ignore beautifulsoup4 pillow playwright requests >/dev/null && python check-images.py" ``` I'll get a report in the console indicating any images that are missing the `loading="lazy"` attribute, especially if they are large. For instance: ![Example output](./images/output.webp) Up to you, now, to act on the results! On my own blog, I've added some missing attributes and optimized some images that were too large. I've reviewed my React components too to ensure they also include lazy loading where appropriate. ## Step 1: Enforcing Lazy Loading in Docusaurus By default, when you write standard Markdown image syntax `![Alt text](image.webp)`, Docusaurus renders a standard HTML `` tag. While modern browsers support the `loading="lazy"` attribute, it's not always added automatically by the framework. To ensure **every single image** on my blog is lazy-loaded (and decoded asynchronously), I overrode the default `img` component in Docusaurus. I've told that story twice already, from the component angle in [Change how Docusaurus will create img tags](/blog/docusaurus-override-img) and from the plugin angle in [Overrides the generation of img tags with Docusaurus](/blog/docusaurus-lazy-loading). This is done in `src/theme/MDXComponents.js`. By wrapping the default image component, I can inject the attributes I need: **src/theme/MDXComponents.js** ```javascript import MDXComponents from "@theme-original/MDXComponents"; export default { // Reusing the default mapping ...MDXComponents, // Override the img tag img: (props) => { const { loading, decoding, className, style, ...rest } = props; return ( ); }, }; ``` With this simple change, every image generated from Markdown now has `loading="lazy"`. ## Step 2: Trust, but Verify Implementing the code is one thing; ensuring it works across hundreds of blog posts is another. I wanted to be sure that: 1. Images indeed have the `loading="lazy"` attribute. 2. Large images are properly handled. 3. The behavior persists even after site updates. Manually inspecting elements in Chrome DevTools is tedious. So, I wrote a **Python script** to automate this. ### The Challenge: Scrolling Static analysis (just downloading the HTML) isn't enough. Some lazy loading implementations (or hydration processes) might depend on the user actually scrolling down the page. To simulate a real user, I've used **Playwright**. It allows me to run a headless browser, render the page, and programmatically scroll to the bottom, triggering any lazy-loading mechanisms. ### The Script The script performs the following actions: 1. **Browses** a list of URLs (my blog posts). 2. **Scrolls** down the page multiple times (simulating a user reading). 3. **Parses** the DOM using `BeautifulSoup`. 4. **Inspects** every `` tag to verify the presence of `loading="lazy"`. 5. **Checks** image dimensions using `Pillow` to flag large images that definitely should be lazy-loaded. **check-images.py** ```python # type: ignore # cspell:ignore networkidle """ Advanced Image Audit Script for React Sites This script uses Playwright to render a React website in a headless Chromium browser, scrolls the page to trigger lazy loading, and audits all tags for: - Lazy loading usage - Image dimensions (width x height) - Format (e.g., WebP, PNG, JPEG) - Compression (file size) - Presence of width/height attributes - Duplicate image sources Usage in Docker: docker run -it --rm \ -v ${PWD}:/app \ -w /app \ --entrypoint /bin/sh \ mcr.microsoft.com/playwright/python:v1.57.0-jammy \ -c "pip install --root-user-action=ignore beautifulsoup4 pillow playwright requests >/dev/null && python lazy-load.py" Notes: - The script ignores SSL errors (useful for local dev or self-signed certs) - It scrolls the page multiple times to ensure lazy-loaded images appear in the DOM - Requires 'beautifulsoup4' and 'playwright' Python packages """ from playwright.sync_api import sync_playwright from bs4 import BeautifulSoup import time import requests from PIL import Image from io import BytesIO from urllib.parse import urljoin import urllib3 # List of pages to check urls = [ "https://host.docker.internal:3000/", # "https://host.docker.internal:3000/about", # "https://host.docker.internal:3000/blog", # "https://host.docker.internal:3000/blog/archive", # add more pages here ] # Disable SSL warnings for unverified HTTPS requests urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def get_image_info(src, base_url): """Returns image size (width, height), format, and file size in KB""" if not src or src.startswith("data:image/"): return None # Skip inline base64 images full_url = urljoin(base_url, src) try: response = requests.get(full_url, stream=True, timeout=5, verify=False) response.raise_for_status() img = Image.open(BytesIO(response.content)) size_kb = len(response.content) / 1024 return { "width": img.width, "height": img.height, "format": img.format, "size_kb": round(size_kb, 1), } except Exception as e: print(f"⚠️ Could not get info for {full_url}: {e}") return None # Scrolling configuration NUM_SCROLLS = 10 # Number of scrolls to perform to trigger lazy-loaded images SCROLL_HEIGHT = 1000 # Pixels per scroll WAIT_PER_SCROLL = 1 # Seconds to wait after each scroll with sync_playwright() as p: browser = p.chromium.launch(headless=True) for url in urls: print(f"\n🔎 Checking page: {url}") page = browser.new_page(ignore_https_errors=True) # Navigate and wait for network to be idle page.goto(url) page.wait_for_load_state("networkidle") # Scroll to trigger lazy loading for _ in range(NUM_SCROLLS): page.mouse.wheel(0, SCROLL_HEIGHT) time.sleep(WAIT_PER_SCROLL) # Get the rendered HTML html = page.content() page.close() # Parse HTML soup = BeautifulSoup(html, "html.parser") images = soup.find_all("img") print(f"{len(images)} images found on {url}\n") lazy_images = [] non_lazy_images = [] warnings = [] for img in images: src = img.get("src") if not src: warnings.append("[missing src] image tag") continue # Check lazy loading is_lazy = img.get("loading") == "lazy" # Check width/height attributes has_dimensions = img.get("width") and img.get("height") if not has_dimensions: warnings.append(f"{src} missing width/height attributes") # Get image info info = get_image_info(src, url) if info is None: continue # Skip if image couldn't be loaded width = info["width"] height = info["height"] format = info["format"] size_kb = info["size_kb"] # Check format if format.lower() in ["jpeg", "jpg", "png"]: warnings.append(f"{src} is {format}, consider converting to WebP") # Check compression if size_kb > 200: warnings.append(f"{src} is {size_kb} KB — consider compressing") # Classify image if is_lazy: lazy_images.append(f"{src} ✅ lazy ({width}x{height}, {format}, {size_kb} KB)") else: non_lazy_images.append(f"{src} ❌ not lazy ({width}x{height}, {format}, {size_kb} KB)") # Print results for entry in non_lazy_images: print(entry) for entry in warnings: print(f"⚠️ Warning: {entry}") print(f"\n✅ Lazy images: {len(lazy_images)}") print(f"❌ Non-lazy images: {len(non_lazy_images)}") print(f"⚠️ Warnings: {len(warnings)}") browser.close() ``` You know me very well now; I like to containerize things. So, I wasn't using Python or Playwright directly on my host machine but rather inside a Docker container. That container mounts the current directory, installs the required packages, and runs `check-images.py` — the exact command shown at the top of this article. > **Note:** > > This script has since been retired from my own toolkit and is kept here only as a reference; the code above still works as documented. ### Additional Checks The script also does these checks: * Check to see if the image format is WebP; if not, it issues a warning. * Check if the image size exceeds a certain threshold (200 KB); if so, it issues a warning. * Check if the image height and width attributes are set; if not, it issues a warning. You can easily modify these checks in the script to fit your own needs. And add yours too! ## Conclusion Performance optimization is a journey, not a destination. By combining a global code override in Docusaurus with an automated validation script, I can write content peacefully, knowing that my images won't slow down the experience for my readers. If you are interested in the full script, you can find it in the `.scripts` folder of my repository! --- # Tags management in Docusaurus > Automate Docusaurus tag management with this Python script. Clean up duplicates, fix casing, and merge tags safely across hundreds of Markdown files via Docker. --- ![Tags management in Docusaurus](/img/v2/tags_manager.webp) **TL;DR** This article introduces a custom Python script designed to automate the cleanup and management of inconsistent tags within a Docusaurus blog. The tool runs in a Docker container to list, rename, and delete tags while providing smart suggestions for merging duplicates or fixing case variations. It also discusses using Docusaurus's native configuration options to enforce stricter tag usage policies moving forward. More than 200 blog posts later, my Docusaurus site has accumulated a bit of tag chaos. Too many similar tags, inconsistent capitalization, singular vs plural forms... Tags with just one or two posts. And so on. In this post, I'll share a **Python script** I wrote to automate tag management like removing some tags, merging others, and cleaning up inconsistencies. It's quite impossible to do by hand when you have hundreds of Markdown files. Plus, doing it manually risks breaking the YAML frontmatter formatting. ## Seeing the Suggestions One command lists every tag and flags the likely duplicates: ```bash $ make tags-manager ARGS="list" ``` ![List of tags](./images/list.webp) ## The Solution: A Smart Tag Manager I wanted a tool that could do three things: 1. **List** all tags by frequency. 2. **Suggest** optimizations (finding duplicates or singular/plural variations). 3. **Rename** or **Delete** tags safely. ### Smart Suggestions The coolest part of the script is the "Optimization Suggestions". It compares every tag against every other tag to find potential duplicates. It looks for: * **Case variations**: `GitHub` vs `github`. * **Plurals**: `snippet` vs `snippets`. * **Substrings**: `visual studio code` vs `vscode`. This helps me spot the mess I didn't even know I had. ## How to Run It (Docker-First) As always, I don't want to pollute my host machine with Python dependencies. I run this script using Docker — exactly like the image checker described in [Running some checks on your Docusaurus images](/blog/docusaurus-check-images). I have a `Makefile` target set up, but essentially, it runs a command like this: ```bash $ docker run -it --rm -v .:/app -w /app --entrypoint /bin/sh python:3.14-slim -c "pip install --root-user-action=ignore oyaml python-frontmatter >/dev/null && python .scripts/tags-manager.py list" ``` But, for sure, that command is quite complex to remember, so I use a [Makefile](/blog/makefile_tips) target instead: **makefile** ``` .PHONY: tags-manager tags-manager: ## Tags manager @clear ifeq ($(strip $(ARGS)),) @echo "--------------------------------------------------------" @echo "🚨 ERROR: Missing command arguments (ARGS)." @echo "--------------------------------------------------------" @echo "USAGE EXAMPLE:" @echo " make tags-manager ARGS=\"delete tag\"" @echo "" @echo " make tags-manager ARGS=\"list\"" @echo " make tags-manager ARGS=\"list --sort count\"" @echo " make tags-manager ARGS=\"list --sort name\"" @echo "" @echo " make tags-manager ARGS=\"rename old,new\"" @echo "" @echo " make tags-manager ARGS=\"--help\"" @echo "" @echo "For full help, run: make tags-manager ARGS=\"--help\"" @echo "--------------------------------------------------------" @exit 1 else @echo "--- Running Tags Manager with arguments: $(ARGS) ---" docker run -it --rm -v ${PWD}:/app -w /app python sh -c "pip install oyaml python-frontmatter > /dev/null 2>&1 && python .scripts/tags-manager.py $(ARGS)" endif ``` ## The Script Here is the full Python script. It uses `argparse` to handle command-line arguments and `python-frontmatter` to parse the files. **.scripts/tags-manager.py** ```python # tag_manager.py import argparse from typing import List, Dict, Any, Tuple, Set import os import glob import subprocess from collections import Counter # YAML processing libraries import oyaml as yaml import frontmatter # --- ANSI Color Codes --- class Colors: """ANSI color codes for console output.""" HEADER = '\033[95m' OKBLUE = '\033[94m' OKCYAN = '\033[96m' OKGREEN = '\033[92m' WARNING = '\033[93m' FAIL = '\033[91m' ENDC = '\033[0m' BOLD = '\033[1m' UNDERLINE = '\033[4m' # --- Configuration --- DOCS_DIR: str = 'blog' FILE_PATTERN_BASE: str = os.path.join(DOCS_DIR, '**', '*.md*') MAX_LENGTH_DIFFERENCE: int = 3 # Max difference in characters to suggest a merge based on substring # Tags to exclude from merge suggestions (case-insensitive and order-independent) # Example: ('php', 'phpcbf') will prevent 'php' vs 'phpcbf' from being suggested. MERGE_EXCEPTIONS: Set[Tuple[str, str]] = { ('ftp', 'sftp'), ('git', 'github'), ('git', 'gitlab'), ('mysql', 'sql'), ('php', 'phpcbf'), ('php', 'phpcs'), ('php', 'phpdoc'), ('scp', 'winscp'), ('ssh', 'sshpass'), ('xml', 'xmlstarlet'), } # --- YAML Dumper Configuration --- def custom_list_representer(dumper: yaml.SafeDumper, data: List[Any]) -> yaml.nodes.SequenceNode: """Forces Python lists to be represented in YAML flow style: [item1, item2].""" return dumper.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=True) def custom_string_representer(dumper: yaml.SafeDumper, data: str) -> yaml.nodes.ScalarNode: """Prevents PyYAML from folding long strings into multi-line blocks.""" if '\n' in data: # Use block style if the string explicitly contains newlines return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') # Use plain style for single-line strings, preventing folding return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='') # Registering custom representers on the SafeDumper yaml.add_representer(list, custom_list_representer, Dumper=yaml.SafeDumper) yaml.add_representer(str, custom_string_representer, Dumper=yaml.SafeDumper) def custom_dumper_factory(metadata: Dict[str, Any], content: str) -> str: """ Generates the final file content string (front matter + markdown content) while preserving YAML formatting. """ import io output = io.StringIO() yaml.dump( metadata, output, Dumper=yaml.SafeDumper, default_flow_style=False, allow_unicode=True, width=4096 # High width ensures long single-line strings are not folded ) yaml_output: str = output.getvalue() return f"---\n{yaml_output}---\n{content}" # ---------------------------------------------------------------------- # Core Tag Management Functions # ---------------------------------------------------------------------- def list_tags(sort_by: str = 'count') -> None: """ Collects and displays all tags, sorted either by count (desc) or name (asc). It also lists tags with case variations and suggests tags for potential merging. Args: sort_by: The primary sorting method ('count' or 'name'). """ tag_counter: Counter = Counter() print(f"{Colors.BOLD}Action:{Colors.ENDC} {Colors.OKBLUE}Listing all tags{Colors.ENDC} (Sort by: {Colors.OKCYAN}{sort_by.upper()}{Colors.ENDC})") print(f"Base Directory: {Colors.OKBLUE}{DOCS_DIR}/{Colors.ENDC}") if not os.path.exists(DOCS_DIR): print(f"{Colors.FAIL}{Colors.BOLD}ERROR: Directory '{DOCS_DIR}' not found.{Colors.ENDC}") return files: List[str] = glob.glob(FILE_PATTERN_BASE, recursive=True) if not files: print(f"{Colors.WARNING}No files found matching '{FILE_PATTERN_BASE}'.{Colors.ENDC}") return print(f"{Colors.OKBLUE}INFO: {len(files)} file(s) found. Processing tags...{Colors.ENDC}") case_variants: Dict[str, List[str]] = {} unique_tags: set[str] = set() for filepath in files: try: post = frontmatter.load(filepath) tags: List[str] = post.metadata.get('tags', []) if isinstance(tags, list): tag_counter.update(tags) for tag in tags: tag_lower = tag.lower() # Fill case variant dictionary if tag_lower not in case_variants: case_variants[tag_lower] = [] if tag not in case_variants[tag_lower]: case_variants[tag_lower].append(tag) # Fill unique tags set (case-sensitive) unique_tags.add(tag) except Exception: pass print("\n" + "=" * 50) if not tag_counter: print(f"{Colors.WARNING}No tags found in the files.{Colors.ENDC}") return # --- PART 1: PRIMARY SORT AND LISTING --- tag_list: List[Tuple[str, int]] = list(tag_counter.items()) if sort_by == 'count': # Default sort: Primary by Count (DESC), Secondary by Tag Name (ASC) tag_list.sort(key=lambda item: item[0]) # Secondary sort by name (ASC) tag_list.sort(key=lambda item: item[1], reverse=True) # Primary sort by count (DESC) sort_desc = "Frequency DESC, Name ASC" elif sort_by == 'name': # Name sort: Only by Tag Name (ASC) tag_list.sort(key=lambda item: item[0]) sort_desc = "Name ASC" print(f"{Colors.HEADER}{Colors.BOLD}Top Tags ({sort_desc}):{Colors.ENDC}") for tag, count in tag_list: print(f" {Colors.OKGREEN}{tag:<30}{Colors.ENDC}{Colors.BOLD}{Colors.OKBLUE}{count}{Colors.ENDC}") print("=" * 50) # --- PART 2 & 3: OPTIMIZATION SUGGESTIONS --- case_issues: List[List[str]] = [ variants for variants in case_variants.values() if len(variants) > 1 ] merge_suggestions: set[Tuple[str, str]] = set() sorted_unique_tags = sorted(list(unique_tags)) # Logic to check if a pair is an exception (case-insensitive and order-independent) def is_exception(t1: str, t2: str) -> bool: t1_lower, t2_lower = t1.lower(), t2.lower() # Create the canonical lowercased and sorted tuple (e.g., ('php', 'phpcbf')) canonical_pair = tuple(sorted((t1_lower, t2_lower))) # Check against the MERGE_EXCEPTIONS set return canonical_pair in MERGE_EXCEPTIONS # Refined merge suggestion logic for i in range(len(sorted_unique_tags)): t1 = sorted_unique_tags[i] t1_lower = t1.lower() for j in range(i + 1, len(sorted_unique_tags)): t2 = sorted_unique_tags[j] t2_lower = t2.lower() # Skip tags that only differ by case if t1_lower == t2_lower: continue # Skip if the pair is explicitly listed as an exception if is_exception(t1, t2): continue pair = tuple(sorted((t1, t2))) # Heuristic 1: Singular/Plural Check (Highly reliable) # Example: 'snippet' vs 'snippets' if (t1_lower + 's' == t2_lower or t2_lower + 's' == t1_lower): merge_suggestions.add(pair) continue # Heuristic 2: Substring with Length Constraint len_diff = abs(len(t1_lower) - len(t2_lower)) if len_diff > 0 and len_diff <= MAX_LENGTH_DIFFERENCE: # Check if the shorter tag is a substring of the longer tag if len(t1_lower) < len(t2_lower): shorter_tag = t1_lower longer_tag = t2_lower else: shorter_tag = t2_lower longer_tag = t1_lower if shorter_tag in longer_tag: merge_suggestions.add(pair) print(f"\n{Colors.WARNING}{Colors.BOLD}--- TAG OPTIMIZATION SUGGESTIONS ---{Colors.ENDC}") # Display Case Issues (Part 2) if case_issues: print(f"{Colors.WARNING}1. Case Variations (Easy Duplicates):{Colors.ENDC}") for variants in sorted(case_issues, key=lambda x: x[0].lower()): counts_info = ", ".join([ f"{v} ({tag_counter[v]})" for v in sorted(variants) ]) print(f" {Colors.FAIL}❌ Case Issue ({variants[0].lower()}): {Colors.ENDC}{counts_info}") print(f" {Colors.OKCYAN} Suggestion: Rename all to a canonical form (e.g., '{variants[0]}').{Colors.ENDC}") # Display Merge Suggestions (Part 3 - Refined) if merge_suggestions: print(f"\n{Colors.WARNING}2. Potential Merge Candidates (Singular/Plural or Small Prefix/Suffix):{Colors.ENDC}") for t1, t2 in sorted(list(merge_suggestions)): count1 = tag_counter[t1] count2 = tag_counter[t2] print(f" {Colors.WARNING}⚠️ Found: {Colors.ENDC}{t1} ({count1}) vs {t2} ({count2})") print(f" {Colors.OKCYAN} Suggestion: Merge into a canonical form (e.g., '{t1}' or '{t2}').{Colors.ENDC}") if not case_issues and not merge_suggestions: print(f" {Colors.OKGREEN}No obvious tag duplicates or merge candidates found.{Colors.ENDC}") print("-" * 50) def run_node_script(script_name: str, extra_args: List[str]) -> int: """ Proxies to a Node script under scripts/ — kept here so the single 'tags' cheatsheet entry stays the one wiring point, while the AI/corpus logic itself (Ollama, blog-corpus helpers) lives in JS and isn't duplicated in Python. Used by 'suggest' (scripts/suggest-tags.mjs) and 'review' (scripts/tags-review.mjs). """ script_root = os.path.dirname(os.path.abspath(__file__)) node_script = os.path.join(script_root, '..', 'scripts', script_name) result = subprocess.run(['node', node_script, *extra_args]) return result.returncode def set_tags_for_file(filepath: str, new_tags: List[str]) -> bool: """ Overwrites one article's frontmatter 'tags' key with new_tags, via the same custom_dumper_factory used by delete/rename — forced flow style ([a, b, c]), including for a file whose tags were still block-style, which this normalizes on the way. """ try: post = frontmatter.load(filepath) except Exception as e: print(f"{Colors.FAIL} ❌ Could not read {filepath}: {e}{Colors.ENDC}") return False post.metadata['tags'] = new_tags full_content: str = custom_dumper_factory(post.metadata, post.content) with open(filepath, 'w', encoding='utf-8') as f: f.write(full_content) print(f"{Colors.OKGREEN} ✓ Tags set to [{', '.join(new_tags)}] in: {filepath}{Colors.ENDC}") return True # ---------------------------------------------------------------------- # Argparse Configuration (Remains unchanged) # ---------------------------------------------------------------------- def _process_files(action: str, old_tag: str = None, new_tag: str = None) -> None: # ... (function body remains unchanged) ... if action == 'delete': operation_desc: str = f"{Colors.FAIL}Deleting tag '{old_tag}'{Colors.ENDC}" elif action == 'rename': operation_desc: str = f"{Colors.OKCYAN}Renaming tag '{old_tag}' to '{new_tag}'{Colors.ENDC}" else: return print(f"{Colors.BOLD}Operation:{Colors.ENDC} {operation_desc}") print(f"Base Directory: {Colors.OKBLUE}{DOCS_DIR}/{Colors.ENDC}") if not os.path.exists(DOCS_DIR): print(f"{Colors.FAIL}{Colors.BOLD}ERROR: Directory '{DOCS_DIR}' not found.{Colors.ENDC}") return files: List[str] = glob.glob(FILE_PATTERN_BASE, recursive=True) if not files: print(f"{Colors.WARNING}No files found matching '{FILE_PATTERN_BASE}'.{Colors.ENDC}") return print(f"{Colors.OKBLUE}INFO: {len(files)} file(s) found. Processing...{Colors.ENDC}") changes_made: int = 0 old_tag_lower: str | None = old_tag.lower() if old_tag else None new_tag_lower: str | None = new_tag.lower() if new_tag else None for filepath in files: try: post = frontmatter.load(filepath) metadata: Dict[str, Any] = post.metadata file_changed: bool = False if 'tags' in metadata and isinstance(metadata['tags'], list): original_tags: List[str] = metadata['tags'] new_tags: List[str] = [] for tag in original_tags: tag_lower: str = tag.lower() if tag_lower == old_tag_lower: if action == 'delete': file_changed = True continue elif action == 'rename': # Avoid adding the new tag if a case-insensitive match already exists if new_tag_lower and new_tag_lower not in [t.lower() for t in new_tags]: new_tags.append(new_tag) file_changed = True else: # Ensure no duplicates in the new list (case-insensitive) if tag_lower not in [t.lower() for t in new_tags]: new_tags.append(tag) if file_changed: post.metadata['tags'] = new_tags # Generate content and write the file manually to preserve YAML formatting full_content: str = custom_dumper_factory(post.metadata, post.content) with open(filepath, 'w', encoding='utf-8') as f: f.write(full_content) if action == 'delete': print(f"{Colors.OKGREEN} ✓ Deleted '{old_tag}' from: {filepath}{Colors.ENDC}") else: print(f"{Colors.OKGREEN} ✓ Renamed '{old_tag}' to '{new_tag}' in: {filepath}{Colors.ENDC}") changes_made += 1 except Exception as e: error_message: str = str(e) print(f"{Colors.FAIL} ❌ FATAL ERROR PROCESSING {filepath}: {error_message}{Colors.ENDC}") print("-" * 50) print(f"{Colors.BOLD}Operation completed.{Colors.ENDC} {changes_made} file(s) modified.") # ... (The rest of the argparse block remains the same) ... if __name__ == "__main__": parser = argparse.ArgumentParser( description=f"{Colors.BOLD}{Colors.OKCYAN}Tag Manager Tool{Colors.ENDC} for Markdown files (path: {DOCS_DIR}).", epilog="Use 'python tag_manager.py -h' for action-specific help.", formatter_class=argparse.RawTextHelpFormatter ) # Define subparsers and actions subparsers = parser.add_subparsers(dest="action", help="Available actions.") # LIST action parser_list = subparsers.add_parser( 'list', help=f"List all tags found in '{DOCS_DIR}', sorted by frequency and name." ) parser_list.add_argument( '--sort', type=str, choices=['name', 'count'], default='count', help="Primary sorting method: 'count' (default, frequency descending) or 'name' (alphabetical ascending)." ) # DELETE action parser_delete = subparsers.add_parser( 'delete', help=f"Delete a specified tag from all files in '{DOCS_DIR}'. (Parameter: TAG)" ) parser_delete.add_argument( "tag", type=str, metavar="", help="The tag to DELETE (e.g., 'draft')." ) # RENAME action parser_rename = subparsers.add_parser( 'rename', help=f"Rename an old tag to a new one in all files in '{DOCS_DIR}'. (Parameters: OLD_TAG,NEW_TAG)" ) parser_rename.add_argument( "tags", type=str, metavar="", help="The tags to rename, in 'old_tag,new_tag' format (e.g., 'prog,programming')." ) # SUGGEST action (proxies to scripts/suggest-tags.mjs — see run_node_script) parser_suggest = subparsers.add_parser( 'suggest', help="Use AI (via scripts/suggest-tags.mjs) to suggest tags for a specific file." ) parser_suggest.add_argument("file", type=str, help="Path to the .md file.") # REVIEW action — listed here for --help only; its own flags are handled before # parser.parse_args() runs (see the argv[1] == 'review' check below). subparsers.add_parser( 'review', help="[--force] [--status] [
] — refresh .todos/0000-tags-review-journal.md via Ollama." ) # SET-TAGS action parser_set_tags = subparsers.add_parser( 'set-tags', help="Overwrite one article's 'tags' frontmatter key. (Parameters: FILE, TAG1,TAG2,...)" ) parser_set_tags.add_argument("file", type=str, help="Path to the .md/.mdx file.") parser_set_tags.add_argument( "tags", type=str, metavar="", help="Comma-separated final tag list, e.g. 'docusaurus,react,component'." ) # Display full help page if no arguments are provided if len(os.sys.argv) == 1: parser.print_help() os.sys.exit(0) # 'review' takes its own free-form flags (--force, --status, an article path), which # argparse's REMAINDER handles unreliably once the first one starts with '-' (a known # argparse quirk). Bypassing argparse for this branch avoids it entirely; the subparser # above still exists so 'review' shows up in --help. if os.sys.argv[1] == 'review': os.sys.exit(run_node_script('tags-review.mjs', os.sys.argv[2:])) # Parse arguments args = parser.parse_args() # Execute actions if args.action == 'list': list_tags(args.sort) elif args.action == 'suggest': os.sys.exit(run_node_script('suggest-tags.mjs', [args.file])) elif args.action == 'set-tags': new_tags: List[str] = [t.strip() for t in args.tags.split(',') if t.strip()] if not new_tags: print(f"{Colors.FAIL}Error: at least one tag is required.{Colors.ENDC}") os.sys.exit(1) if not os.path.isfile(args.file): print(f"{Colors.FAIL}Error: file not found: {args.file}{Colors.ENDC}") os.sys.exit(1) os.sys.exit(0 if set_tags_for_file(args.file, new_tags) else 1) elif args.action == 'delete' or args.action == 'rename': old_tag: str | None = None new_tag: str | None = None if args.action == 'delete': old_tag = args.tag elif args.action == 'rename': if ',' not in args.tags: print(f"{Colors.FAIL}Error: Rename argument must be in 'OLD_TAG,NEW_TAG' format.{Colors.ENDC}") os.sys.exit(1) try: old_tag, new_tag = [t.strip() for t in args.tags.split(',', 1)] if not old_tag or not new_tag: print(f"{Colors.FAIL}Error: Both tags (old and new) must be specified and non-empty.{Colors.ENDC}") os.sys.exit(1) except ValueError: print(f"{Colors.FAIL}Error: Problem splitting tags. Expected format: 'OLD_TAG,NEW_TAG'.{Colors.ENDC}") os.sys.exit(1) _process_files( action=args.action, old_tag=old_tag, new_tag=new_tag ) ``` ## More Examples **Renaming a tag:** If, by looking at the list of tags, I see I have a `snippets` tag and a `snippet` one, I'll merge them like this: ```bash $ make tags-manager ARGS="rename snippets,snippet" ``` **Deleting a tag:** I can also find that a specific tag doesn't add any value so, to remove it, I'll run something like this: ```bash $ make tags-manager ARGS="delete draft" ``` **Bonus - The tags.yml file** Natively, Docusaurus allows you to create a special file called `blog/tags.yml`. Here is the one I'm using: **blog/tags.yml** ```yaml adminer: label: "Adminer" permalink: "/tags/adminer" description: "Lightweight web-based database management tool supporting MySQL, PostgreSQL, SQLite, and more." ai: label: "Artificial Intelligence (AI)" permalink: "/tags/ai" description: "Tools, models, and concepts related to AI, including Ollama, Claude, and Gemini." apache: label: "Apache HTTP Server" permalink: "/tags/apache" description: "Configuration and troubleshooting for the Apache web server." api: label: "API" permalink: "/tags/api" description: "Development, consumption, and documentation of APIs (REST, OpenAPI, FastAPI, PostgREST)." bash: label: "Bash" permalink: "/tags/bash" description: "Scripts, commands, and tips for the Bash shell." bluesky: label: "Bluesky" permalink: "/tags/bluesky" description: "Bluesky social network — integration, sharing, and embedding in web projects." code-quality: label: "Code Quality" permalink: "/tags/code-quality" description: "Tools and practices to improve code quality: static analysis, linters, formatters, and Git hooks (PHPStan, Ruff, pre-commit, etc.)." component: label: "Component / UI" permalink: "/tags/component" description: "Creation and management of React or Docusaurus UI components." customization: label: "Customization" permalink: "/tags/customization" description: "Personalizing shell environments, terminals, editors, or applications." database: label: "Database" permalink: "/tags/database" description: "Concepts, administration, and queries for relational and NoSQL databases (MySQL, PostgreSQL, Oracle, SQL Server, Access)." devcontainer: label: "DevContainer" permalink: "/tags/devcontainer" description: "Containerized development environments using VS Code Dev Containers and Docker." doc-as-code: label: "Documentation as Code" permalink: "/tags/doc-as-code" description: "Managing documentation like software: diagrams (Mermaid, Markmap), Quarto slideshows, Pandoc, and visualization tools." docker: label: "Docker" permalink: "/tags/docker" description: "Containerization, Docker Compose, and image management." docusaurus: label: "Docusaurus" permalink: "/tags/docusaurus" description: "Building, customizing, and deploying Docusaurus static sites." excel: label: "Microsoft Excel" permalink: "/tags/excel" description: "Formulas, VBA macros, and advanced usage of Microsoft Excel." fzf: label: "fzf" permalink: "/tags/fzf" description: "Fuzzy finder for the command line — interactive search and selection." git: label: "Git" permalink: "/tags/git" description: "Commands, workflow, and version control with Git." github: label: "GitHub" permalink: "/tags/github" description: "GitHub platform, Actions, features, and integrations." gitlab: label: "GitLab" permalink: "/tags/gitlab" description: "GitLab platform, CI/CD pipelines, and project management." joomla: label: "Joomla" permalink: "/tags/joomla" description: "Joomla Content Management System installation and configuration." laravel: label: "Laravel" permalink: "/tags/laravel" description: "Laravel PHP framework — events, Filament, Telescope, and testing." linux: label: "Linux" permalink: "/tags/linux" description: "Administration, commands, and tips for Linux systems (Ubuntu, tools, shell utilities)." makefile: label: "Makefile" permalink: "/tags/makefile" description: "Automating tasks and workflows with GNU Make." markdown: label: "Markdown" permalink: "/tags/markdown" description: "Syntax, tools, and conversion of Markdown documents." msaccess: label: "Microsoft Access" permalink: "/tags/msaccess" description: "Database development and VBA automation with Microsoft Access." mysql: label: "MySQL" permalink: "/tags/mysql" description: "MySQL relational database — configuration, queries, and Docker-based setups." nodejs: label: "Node.js" permalink: "/tags/nodejs" description: "Server-side JavaScript runtime environment and its ecosystem." npm: label: "npm" permalink: "/tags/npm" description: "Node package manager — installing and managing JavaScript dependencies." ollama: label: "Ollama" permalink: "/tags/ollama" description: "Local environment to run and manage large language models on your own machine." oracle: label: "Oracle Database" permalink: "/tags/oracle" description: "Running and querying Oracle Database, including Docker-based setups." php: label: "PHP" permalink: "/tags/php" description: "PHP development, environment setup, and tips." phpmyadmin: label: "phpMyAdmin" permalink: "/tags/phpmyadmin" description: "Web-based MySQL/MariaDB administration interface." postgresql: label: "PostgreSQL" permalink: "/tags/postgresql" description: "PostgreSQL relational database — configuration, queries, and Docker-based setups." python: label: "Python" permalink: "/tags/python" description: "Python language development, data processing, and its ecosystem (FastAPI, Pandas)." quarto: label: "Quarto" permalink: "/tags/quarto" description: "Scientific and technical publishing with Quarto — documents, slideshows, and reports." react: label: "React" permalink: "/tags/react" description: "React and JavaScript — UI development, components, and tooling." security: label: "Security" permalink: "/tags/security" description: "Web security, protection tools, and secure configuration." self-hosted: label: "Self-Hosted" permalink: "/tags/self-hosted" description: "Software solutions hosted on your own server (Uptime Kuma, Roundcube, LimeSurvey, Matomo, etc.)." ssh: label: "SSH (Secure Shell)" permalink: "/tags/ssh" description: "Secure remote connection, file transfer (SFTP, SCP), and SSH configuration." ssl: label: "SSL / TLS" permalink: "/tags/ssl" description: "Security certificates, HTTPS configuration, and the SSL/TLS protocol." tests: label: "Testing" permalink: "/tags/tests" description: "Strategies and tools for testing: unit (PHPUnit, Pest), functional (Cypress, Behat), and BDD." vba: label: "VBA / VBScript" permalink: "/tags/vba" description: "Programming macros in Microsoft Office applications (Excel, Access) and Windows scripting with VBScript." vscode: label: "VS Code" permalink: "/tags/vscode" description: "Configuration, extensions, tips, and productivity features for Visual Studio Code." windows: label: "Microsoft Windows" permalink: "/tags/windows" description: "Windows administration, DOS/cmd, PowerShell, WinGet, and Windows-specific tooling." windows-terminal: label: "Windows Terminal" permalink: "/tags/windows-terminal" description: "Using and configuring the Windows Terminal application." winscp: label: "WinSCP" permalink: "/tags/winscp" description: "Graphical SFTP/FTP/SCP client for Windows, including PuTTY integration." wsl: label: "WSL (Windows Subsystem for Linux)" permalink: "/tags/wsl" description: "Running a Linux environment on Windows using WSL2." yarn: label: "Yarn" permalink: "/tags/yarn" description: "Fast, reliable, and secure dependency management for JavaScript." zsh: label: "Zsh" permalink: "/tags/zsh" description: "The Z shell — configuration, plugins, functions, and productivity." ``` With such file, you can then configure your `docusaurus.config.js` file and set the `onInlineTags` attribute to `warn` or `throw`. Need more info, read the [Tags File](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs#tags-file) official documentation. ## Conclusion Keeping a blog organized is a continuous process. With this script, I can quickly audit my tags and ensure that `Docusaurus` doesn't become `docusaurus` or `DocuSaurus` over time. Feel free to grab the script and adapt it to your own static site generator! --- # Automating TL;DR Summaries with Gemini AI > Improve your reader's experience by automatically generating concise TL;DR summaries for your blog posts using Google's Gemini AI and Python. --- ![Automating TL;DR Summaries with Gemini AI](/img/v2/gemini_tldr.webp) **TL;DR** Writing summaries for long blog posts improves user experience but can be time-consuming. This article explains how to automate the generation of "Too Long; Didn't Read" (TL;DR) sections using a Python script and Google's Gemini AI. We'll cover the Docker setup, the script usage, and how to integrate it into your Docusaurus workflow. As a technical blogger, I was searching for a way to enhance my readers' experience by providing concise summaries at the beginning of my articles — so they don't waste time (I hope!) reading a full article only to think, "Wait, that's not what I was looking for." So, I decided to automate the generation of TL;DR summaries using Google's Gemini AI and a simple Python script. In this article, I'll explain how I did it. *This is not my only use of Gemini on this blog: read [How I used Google Gemini Nano Banana on my blog](/blog/gemini-meerkat) for the illustrations side of the story.* **Quick Jump:** [Before and After](#before-and-after) | [Get All the Files](#conclusion) ## Before and After The script takes an entire article — every paragraph, every heading — and reduces it to a few sentences. Here's a real example, taken straight from [the Lovable.dev article](/blog/lovable-dev-ai) on this blog. **Before** — the opening paragraphs the script actually reads: > This week, a colleague told me about Lovable.dev, saying: "*In a prompt, you just have to describe the program you want to generate, and the tool will build it and even deploy it for you*". Wow, I definitely needed to try it. > > But what should I ask for? What if I asked it to create a clone of Marknotes? Those who have been following me for years know that I created Marknotes, a note-taking application, 10 years ago. I worked on it for five years before moving on. > > Let's see if Lovable.dev can build the same thing in just one hour. **Spoiler: it didn't, but it wasn't too bad.** **After** — the `` the script generated and injected at the top of that same article: ![The generated TLDR, rendered on the actual published article](./images/example_tldr_output.png) Same information density readers actually need, in a fraction of the reading time. ## The Concept The idea is simple: I need some script that will scan my blog post (one or more) and generate a short summary using Gemini AI. Then, it should insert that summary into the blog post, wrapped in a custom `` component for easy styling and visibility. The script will do the following: 1. Get a path as input (file or folder). 2. Read the Markdown files. 3. Send the content to Gemini AI for summarization. 4. Receive the summary. 5. Inject the summary back in the Markdown file. My folder structure looks like this: `blog/YYYY/MM/DD/index.md` for each blog post. So by calling the script with `blog/2026/01/`, it will process all posts from January 2026. ## The React Component First, we need a way to display the summary nicely in Docusaurus. I use a simple custom component for this. Here is the implementation: ```html This is the summary of the article... ``` The component code is as follows: **src/components/TLDR/index.tsx** ```typescript import { useMemo, type JSX, type ReactNode } from "react"; import Head from "@docusaurus/Head"; import MobileQuickLinks from "@site/src/components/Blog/MobileQuickLinks"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // Recursively extracts plain text from React nodes for JSON-LD abstract function extractText(node: unknown): string { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(extractText).join(""); if (node && typeof node === "object") { const children = (node as { props?: { children?: unknown } }).props?.children; if (children) return extractText(children); } return ""; } interface Props { children: ReactNode; } // Injects JSON-LD so search engines read the abstract via Schema.org BlogPosting export default function TLDR({ children }: Props): JSX.Element { const structuredData = useMemo( () => ({ "@context": "https://schema.org", "@type": "BlogPosting", abstract: extractText(children), }), [children], ); return ( <>
⚡
TL;DR
{children}
{/* The TL;DR is the one block every post opens with, which makes it the only reliable anchor for a "read next" hint placed *after* the reader has confirmed the article is the right one. MobileQuickLinks decides on its own whether it has anything to show. */} ); } ``` **src/components/TLDR/styles.module.css** ```css /* * TL;DR — was borrowing Infima's "alert alert--info" utility classes, which is * where its unexplained Docusaurus blue came from. The summary is the first * block of every article on the site: it should carry the site's own colour, * not a framework default. */ .tldrContainer { composes: mk-callout from global; composes: mk-callout--key from global; } /* * font-variant-emoji: text asks for the monochrome TEXT presentation of the * lightning character, so it takes --mk-on-line from .mk-dot like every other * dot glyph in the site. Without it the emoji keeps its own yellow-blue * colours on the terracotta disc — the one bicolour pastille on the page. * index.tsx needs no change. */ .tldrIcon { composes: mk-dot from global; font-size: 0.9rem; font-variant-emoji: text; } .tldrBody { min-width: 0; } .tldrTitle { composes: mk-eyebrow from global; display: block; margin-bottom: 0.25rem; color: inherit; } /* * The old rule indented the summary by a hardcoded 2.5rem to clear the icon, * and dimmed it with opacity: 0.9. Both are gone: the flex row aligns the text * for free, and a summary is the last thing on the page that should be faded. */ .tldrContent { font-size: 0.95rem; line-height: 1.65; } .tldrContent > :last-child { margin-bottom: 0; } .tldrContent a { color: var(--tone-line); text-decoration: underline; text-underline-offset: 2px; } ``` ## The Automation Script In my blog, I've created a `.scripts/python_tldr` folder to hold the Python script and its dependencies. > **Gemini API:** > > You'll need a Google Gemini API key for this to work. You can get one from . ### Running with Docker Because I'm a huge fan of Docker, I wanted to ensure that the script runs in a consistent environment. Here is how to start the container: ```bash $ docker run -it --rm --env-file .env -v .:/app -w /app python sh -c "pip install google-genai python-dotenv > /dev/null 2>&1 && /bin/bash" ``` That command will create a Python container, mount your current directory to `/app`, read and load your environment file, install the required packages (`google-genai` for Gemini API access and `python-dotenv` for environment variable management), and start a bash shell where you can run the script. > **Environment Variables:** > > You'll need to create a `.env` file in the root of your project with the following content: > > **.env** > > ``` > GOOGLE_API_KEY=your_gemini_api_key_here > ``` ### Generating Summaries Once inside the container, you can run the script against a specific file or an entire directory. To process a single file: ```bash $ python .scripts/python_tldr/main.py blog/2026/01/index.md ``` To process all posts for a specific month: ```bash $ python .scripts/python_tldr/main.py blog/2026/01/ ``` The script is smart enough to skip files that already have a `` tag, so you can run it safely on your entire blog archive. > **Be transparent with your readers:** > > A summary written by a machine is still machine-written content. I flag it explicitly on every post concerned; see [How to indicate AI-assisted content in a Docusaurus blog](/blog/docusaurus-ai-gemini). The same *generate once at authoring time, commit the result* pattern is reused in [AI-Powered Code Tooltips in Docusaurus](/blog/docusaurus-eli5-snippet-tooltips): readers never wait for an API call and your key never leaves your machine. ## Error - 429 RESOURCE\_EXHAUSTED This error indicates that you've exceeded your usage limits for the Gemini API. Depending on your plan, there may be restrictions on the number of requests you can make or the amount of data you can process within a certain time frame. *The free plan is quite limited, so if you have a lot of blog posts, you might want to consider upgrading your plan or running the script in batches to avoid hitting the limit.* You can still use the same feature but manually: go to , paste the content of your blog post, and ask Gemini to generate a TL;DR summary for you. Then, copy the result and paste it into your blog post inside the `` component. Here is the prompt used to get the summary: ```text You are an expert technical editor. Read the following article content and generate a 'TL;DR' summary. The summary must be concise (max 3 sentences) and written in the same language as the article. ``` ## Conclusion You'll find below all the files you'll need to set this up in your own Docusaurus blog. Feel free to adapt and improve the script as needed! **Project: /your\_docusaurus\_site** > **Note:** > > Now, please run 'docker run -it --rm -v .:/app -w /app python sh -c "pip install google-genai python-dotenv && python .scripts/python\_tldr/main.py blog/"' to add the TLDR summary in each blog post under /blog/. **.env** ``` GOOGLE_API_KEY=your_gemini_api_key_here ``` **.scripts/python\_tldr/main.py** ```python # How to run: # # 1. Ensure you have Docker installed. # 2. Place your Gemini API key in a .env file at the root of your blog: # GEMINI_API_KEY=your_actual_api_key_here # 3. Start a terminal, go to your blog and run the following command, # docker run -it --rm -v ${PWD}:/app -w /app python sh -c "pip install google-genai python-dotenv > /dev/null 2>&1 && /bin/bash" # 4. Inside the container, run: # python .scripts/python_tldr/main.py # # Examples: # Process the entire blog directory: # python .scripts/python_tldr/main.py blog/ # # Process a specific year i.e. all posts from 2026: # python .scripts/python_tldr/main.py blog/2026/ # # Process a specific year and month i.e. all posts from January 2026: # python .scripts/python_tldr/main.py blog/2026/01 # # Process a single blog post: # python .scripts/python_tldr/main.py blog/2026/01/05 import argparse import os import sys from pathlib import Path from typing import List, Final from dotenv import load_dotenv # --- PATH PATCH --- # Ensures 'src' module is found regardless of execution context BASE_DIR = Path(__file__).resolve().parent if str(BASE_DIR) not in sys.path: sys.path.insert(0, str(BASE_DIR)) # ------------------ try: from src.ai_service import AIService from src.file_manager import FileManager except ImportError as e: print(f"❌ Configuration Error: Could not import internal modules.\nDetails: {e}") sys.exit(1) def process_single_file(file_path: Path, ai_service: AIService) -> str: """ Processes a single markdown file: reads, checks for existence, generates TL;DR, and injects. Returns a status string for logging. """ try: file_manager = FileManager(file_path) content = file_manager.read_content() # 1. Skip if already exists if file_manager.has_tldr(content): return "SKIPPED (Exists)" # 2. Generate print(f" Generating summary for {file_path.name}...") tldr = ai_service.generate_tldr(content) # 3. Inject file_manager.inject_tldr(content, tldr) return "SUCCESS" except Exception as e: return f"ERROR: {e}" def main() -> None: """Main execution entry point.""" load_dotenv(dotenv_path=BASE_DIR / ".env") if not os.getenv("GEMINI_API_KEY"): load_dotenv() # Fallback parser = argparse.ArgumentParser(description="Inject AI-generated TL;DR into markdown files recursively.") parser.add_argument("path", type=Path, help="Path to a markdown file or a directory containing markdown files.") args = parser.parse_args() target_path: Path = args.path if not target_path.is_absolute(): target_path = Path.cwd() / target_path if not target_path.exists(): print(f"❌ Error: Path not found: {target_path}") sys.exit(1) # Validate API Key api_key: str | None = os.getenv("GEMINI_API_KEY") if not api_key: print("❌ Error: GEMINI_API_KEY not found.") sys.exit(1) # Initialize AI Service once (reused for all files) try: ai_service = AIService(api_key) except Exception as e: print(f"❌ Error initializing AI Service: {e}") sys.exit(1) # Collect files to process files_to_process: List[Path] = [] if target_path.is_file(): if target_path.suffix.lower() == ".md": files_to_process.append(target_path) elif target_path.is_dir(): # Recursive search for all .md files print(f"📂 Scanning directory: {target_path} ...") files_to_process = list(target_path.rglob("*.md")) if not files_to_process: print("⚠️ No Markdown files found to process.") sys.exit(0) print(f"🔍 Found {len(files_to_process)} file(s). Starting processing...\n") # Stats counters stats = {"SUCCESS": 0, "SKIPPED": 0, "ERROR": 0} for i, file_path in enumerate(files_to_process): print(f"📄 Processing [{i+1}/{len(files_to_process)}]: {file_path.name}...", end="", flush=True) status = process_single_file(file_path, ai_service) if "SKIPPED" in status: stats["SKIPPED"] += 1 print(f"\r⚠️ {file_path.name}: {status}") # Pas besoin de dormir si on a sauté le fichier elif "ERROR" in status: stats["ERROR"] += 1 print(f"\r❌ {file_path.name}: {status}") # En cas d'erreur 429, on pourrait vouloir attendre plus, # mais ici on continue simplement. else: stats["SUCCESS"] += 1 print(f"\r✅ {file_path.name}: Injected successfully.") # Final Summary print("\n" + "="*30) print(" BATCH COMPLETE") print("="*30) print(f"✅ Injected: {stats['SUCCESS']}") print(f"⏭️ Skipped: {stats['SKIPPED']}") print(f"❌ Failed: {stats['ERROR']}") print("="*30) if __name__ == "__main__": main() ``` **.scripts/python\_tldr/src/ai\_service.py** ```python import os import time import logging from typing import Final from google import genai from google.genai import types from google.genai.errors import ClientError class AIService: """ Handles interactions with the Google Gen AI SDK (v1.0+). Includes retry logic for rate limits (429). """ # Let's switch to the standard Flash 2.0. # It often has a more stable pool than the 'Lite' or 'Preview' versions. _MODEL_NAME: Final[str] = "gemini-2.0-flash" def __init__(self, api_key: str | None) -> None: if not api_key: raise ValueError("API Key is missing. Check your .env file.") self._client = genai.Client(api_key=api_key) def generate_tldr(self, content: str) -> str: """ Generates a concise TL;DR summary using Gemini. Includes exponential backoff for 429 Resource Exhausted errors. """ prompt: str = ( "You are an expert technical editor. " "Read the following article content and generate a 'TL;DR' summary. " "The summary must be concise (max 3 sentences) and written in the same language as the article. " "Do NOT use blockquotes (>), markdown formatting, or introductory text. " "Just provide the raw summary text.\n\n" f"Content:\n{content}" ) max_retries = 5 base_delay = 5 # Start with 5 seconds for attempt in range(max_retries): try: response = self._client.models.generate_content( model=self._MODEL_NAME, contents=prompt, config=types.GenerateContentConfig( temperature=0.3, ) ) if not response.text: raise RuntimeError("Empty response from Gemini API.") return response.text.strip().lstrip(">").strip() except ClientError as e: # Check if it is a Quota/Rate Limit error (429) if e.code == 429: if attempt == max_retries - 1: raise RuntimeError(f"Max retries exceeded after 429 error: {e}") from e # Calculate wait time: 5s, 10s, 20s, 40s... wait_time = base_delay * (2 ** attempt) print(f"\n ⏳ Quota hit (429). Waiting {wait_time}s before retry {attempt + 1}/{max_retries}...") time.sleep(wait_time) else: # If it's another error (400, 404, 500), crash immediately raise RuntimeError(f"AI Generation failed: {e}") from e except Exception as e: raise RuntimeError(f"Unexpected error: {e}") from e raise RuntimeError("Failed to generate TL;DR after retries.") ``` **.scripts/python\_tldr/src/file\_manager.py** ```python from pathlib import Path from typing import Final, List class FileManager: """ Handles file reading, content checking, and safe injection. """ _FRONTMATTER_DELIMITER: Final[str] = "---" _IMAGE_START_TAG: Final[str] = "![" _TLDR_TAG: Final[str] = "" def __init__(self, file_path: Path) -> None: if not file_path.exists(): raise FileNotFoundError(f"File not found: {file_path}") self._file_path = file_path def read_content(self) -> str: """Reads the file content securely.""" return self._file_path.read_text(encoding="utf-8") def has_tldr(self, content: str) -> bool: """ Checks if the TLDR component is already present in the content. """ return self._TLDR_TAG in content def inject_tldr(self, original_content: str, tldr_text: str) -> None: """ Injects the TL;DR into the content using the custom React component. Inserts after the first image following the frontmatter. """ lines: List[str] = original_content.splitlines() insertion_index: int = 0 # 1. Detect Frontmatter end if lines and lines[0].strip() == self._FRONTMATTER_DELIMITER: try: frontmatter_end_index = lines.index(self._FRONTMATTER_DELIMITER, 1) insertion_index = frontmatter_end_index + 1 except ValueError: insertion_index = 0 # 2. Scan for the first image AFTER the frontmatter for i in range(insertion_index, len(lines)): line = lines[i].strip() if line.startswith(self._IMAGE_START_TAG): insertion_index = i + 1 break # 3. Prepare the React Component content formatted_tldr = ( f"\n{self._TLDR_TAG}\n" f"{tldr_text}\n" f"\n" ) # 4. Insert and Save lines.insert(insertion_index, formatted_tldr) new_content = "\n".join(lines) self._file_path.write_text(new_content, encoding="utf-8") print(f"Successfully injected React into {self._file_path.name} (Line {insertion_index + 1})") ``` **src/components/TLDR/index.tsx** ```typescript import { useMemo, type JSX, type ReactNode } from "react"; import Head from "@docusaurus/Head"; import MobileQuickLinks from "@site/src/components/Blog/MobileQuickLinks"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // Recursively extracts plain text from React nodes for JSON-LD abstract function extractText(node: unknown): string { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(extractText).join(""); if (node && typeof node === "object") { const children = (node as { props?: { children?: unknown } }).props?.children; if (children) return extractText(children); } return ""; } interface Props { children: ReactNode; } // Injects JSON-LD so search engines read the abstract via Schema.org BlogPosting export default function TLDR({ children }: Props): JSX.Element { const structuredData = useMemo( () => ({ "@context": "https://schema.org", "@type": "BlogPosting", abstract: extractText(children), }), [children], ); return ( <>
⚡
TL;DR
{children}
{/* The TL;DR is the one block every post opens with, which makes it the only reliable anchor for a "read next" hint placed *after* the reader has confirmed the article is the right one. MobileQuickLinks decides on its own whether it has anything to show. */} ); } ``` **src/components/TLDR/styles.module.css** ```css /* * TL;DR — was borrowing Infima's "alert alert--info" utility classes, which is * where its unexplained Docusaurus blue came from. The summary is the first * block of every article on the site: it should carry the site's own colour, * not a framework default. */ .tldrContainer { composes: mk-callout from global; composes: mk-callout--key from global; } /* * font-variant-emoji: text asks for the monochrome TEXT presentation of the * lightning character, so it takes --mk-on-line from .mk-dot like every other * dot glyph in the site. Without it the emoji keeps its own yellow-blue * colours on the terracotta disc — the one bicolour pastille on the page. * index.tsx needs no change. */ .tldrIcon { composes: mk-dot from global; font-size: 0.9rem; font-variant-emoji: text; } .tldrBody { min-width: 0; } .tldrTitle { composes: mk-eyebrow from global; display: block; margin-bottom: 0.25rem; color: inherit; } /* * The old rule indented the summary by a hardcoded 2.5rem to clear the icon, * and dimmed it with opacity: 0.9. Both are gone: the flex row aligns the text * for free, and a summary is the last thing on the page that should be faded. */ .tldrContent { font-size: 0.95rem; line-height: 1.65; } .tldrContent > :last-child { margin-bottom: 0; } .tldrContent a { color: var(--tone-line); text-decoration: underline; text-underline-offset: 2px; } ``` > **Gemini limitations:** > > Based on your Gemini AI plan, there may be limitations on the number of requests or the length of text you can process. Be sure to check your plan details to avoid unexpected charges or interruptions. > > Currently, my own plan allows me run 20 calls a day. > **src/theme/MDXComponents.js:** > > Please don't forget to update your `src/theme/MDXComponents.js` file to include the new `TLDR` component, so Docusaurus knows how to render it. If you don't have that file yet, you can create it. Here is the content: > > **src/theme/MDXComponents.js** > > ```javascript > // [...] > > // highlight-next-line > import TLDR from "@site/src/components/TLDR"; > > export default { > // Reusing the default mapping > ...MDXComponents, > // [...] > // highlight-next-line > TLDR, > // [...] > }; > ``` --- # Introducing the ProjectSetup Component - A Standardized Way to Share Project Structures > Learn how to use the new ProjectSetup component to easily share project structures in your Docusaurus blog posts, complete with interactive file snippets and automated setup scripts. --- ![Introducing the ProjectSetup Component - A Standardized Way to Share Project Structures](/img/v2/project_setup.webp) **TL;DR** The `ProjectSetup` component is a new tool for Docusaurus that allows you to share project structures in a standardized and interactive way. It displays file structures with collapsible snippets, generates setup scripts, and offers ZIP downloads, making it easier for readers to replicate projects from your blog posts. In my blog, I often share [components that I have created for Docusaurus](/blog/docusaurus-series). To enable readers to easily reproduce these projects, I was looking for an interactive and standardized way to share file trees. This led me to create the `ProjectSetup` component. It allows you to display the structure of a project in a clear and interactive way, with code snippets for each file. But its real power, its "**killer feature**" is this: with a single click on the **“Generate install script”** button, you get a shell command. Copy it, paste it into your Linux terminal, run it, and... that's it. The entire project tree, including folders and files, is created for you. For those who prefer, the component also offers the option to download the project as a ZIP file. In this article, I'll show you how it works and how you can integrate it to effectively share your own projects. **Quick Jump:** [Seeing It Work](#seeing-it-work) | [Installing the Rest](#installing-the-rest) ## Seeing It Work The best way to show you how to use `ProjectSetup` is to use the component itself. Here is how you can install the `LogoIcon` component in your own Docusaurus project. **Project: src/components/Blog/LogoIcon** > **Note:** > > Install the dependency: `npm install @iconify/react` **src/components/Blog/LogoIcon/index.tsx** ```typescript import type { JSX } from "react"; import { Icon, addCollection } from "@iconify/react"; import { iconCollections } from "./iconBundle.generated"; // Register the bundled icon data once, at module load time, so it is already available // (synchronously, on both server and client) before the first ever renders. This // avoids the React hydration mismatch (#418) caused by @iconify/react's default behaviour // of fetching icon data from its API asynchronously: SSR would render an empty placeholder // while the client, once the fetch resolved, rendered the actual SVG. Object.values(iconCollections).forEach((collection) => addCollection(collection)); interface Props { name: string; size?: number | string; className?: string; "aria-label"?: string; } export default function LogoIcon({ name, size = 48, className, "aria-label": ariaLabel, }: Props): JSX.Element { return ( ); } ``` At this stage, you can think, oh damned, it can be tedious to write the HTML code behind this nice **📦 Project setup: src/components/Blog/LogoIcon** box here above. In fact, not at all. All the rendering is handled by the `ProjectSetup` component itself. Here below the Markdown code I've written in my Docusaurus article: ```markdown Install the dependency: `npm install @iconify/react` ``` > **Always up-to-date:** > > And you know what? The `Snippet` component used here is smart enough to read the file structure and content of the `src/components/Blog/LogoIcon/index.tsx` file, and to render it in this nice interactive way. You just have to write the Markdown code above, and the component does the rest. Your documentation is always up-to-date with the actual file content, and you don't have to worry about formatting or styling. You just write the Markdown code, and the component takes care of the rest. ## What is ProjectSetup? `ProjectSetup` is a React component that allows you to display a project's file structure in a clean, interactive, and standardized way. It not only shows the files and their content but also provides tools to scaffold the project with a single command or download it as a ZIP file. This post illustrates how to use the `ProjectSetup` component to share project structures in your Docusaurus blog posts, making it easier for your readers to replicate and learn from your projects. ## Core Features * **Interactive File Viewing**: It displays files in collapsible snippets, with syntax highlighting. * **Shell Script Generation**: It automatically generates a bash script to create the entire folder and file structure. * **ZIP Download**: It allows users to download the complete project as a ZIP archive. * **Post-installation Guidelines**: You can add instructions to be displayed after the setup. ## Dependencies The `ProjectSetup` component relies on two other components: 1. **`LogoIcon`**: A simple component that renders an icon based on the file type, making the UI more intuitive. It uses the popular `@iconify/react` library. Logo Icon has been created by ![Docux](/img/docux.webp) [Docux](https://github.com/Juniors017), please check out his work: [Component LogoIcon](https://docuxlab.com/blog/logoicon-component-docusaurus/). 2. **`Snippet`**: This component is responsible for displaying individual files. It's a collapsible container that shows the filename, a relevant icon, and the code within. Read my previous article on this subject: [A component for showing code snippets in a Docusaurus blog](/blog/docusaurus-snippets) ## Installing the Rest Here is how to install the remaining `Snippet` and `ProjectSetup` components in your own Docusaurus project. ### 1. Installing `Snippet` Now, let's set up the `Snippet` component. It has more dependencies. **Project: src/components/Snippet** > **Note:** > > Install dependencies: `npm install clsx` **src/components/Snippet/index.tsx** ```typescript import React, { useState, useRef, useEffect, useCallback, useMemo, useId, type CSSProperties, type JSX, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import Prism from "prismjs"; import { useTokenize } from "prism-react-renderer"; // Eli5CodeBlock (below) needs its own Prism grammars registered on this exact // `prismjs` singleton — everything beyond markup/html/xml/css/clike/javascript // (Prism's default bundle) only exists here as a side effect of requiring its // component file. `prism-react-renderer` (peer-depends on "prismjs": "*", so // it shares this same module instance) registers `additionalLanguages` the // same way whenever a native `` renders — meaning `Prism.languages // .bash` etc. was previously only defined here if some *other* CodeBlock had // already rendered first in this process/session. Registering every grammar // `Eli5CodeBlock` can receive (see remark-snippet-loader's `extensionToLang` // map) unconditionally, here, makes that no longer order-dependent. // markup/html/xml/css/javascript need no entry — already in Prism's default // bundle (verified empirically). import "prismjs/components/prism-jsx"; import "prismjs/components/prism-typescript"; import "prismjs/components/prism-python"; import "prismjs/components/prism-markup-templating"; // prism-php's own dependency import "prismjs/components/prism-php"; import "prismjs/components/prism-bash"; import "prismjs/components/prism-json"; import "prismjs/components/prism-yaml"; import "prismjs/components/prism-markdown"; import "prismjs/components/prism-docker"; import "prismjs/components/prism-ini"; import "prismjs/components/prism-sql"; import CodeBlock from "@theme/CodeBlock"; import IconCopy from "@theme/Icon/Copy"; import IconSuccess from "@theme/Icon/Success"; import LogoIcon from "@site/src/components/Blog/LogoIcon"; import clsx from "clsx"; import { useVarResolver } from "@site/src/components/Vars/store"; import { substitutePlainText, substituteChildren, } from "@site/src/components/Vars/substitute"; import VarToken from "@site/src/components/Vars/VarToken"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; // Extract language from inside children const getLanguageFromChildren = (children: ReactNode): string | null => { const findLang = (node: ReactNode): string | null => { if (React.isValidElement(node)) { const props = node.props as { className?: unknown; children?: ReactNode; }; if (typeof props.className === "string") { const match = props.className.match(/language-(\w+)/); if (match) return match[1].toLowerCase(); } if (props.children) { const nested = props.children; if (Array.isArray(nested)) { for (const child of nested) { const lang = findLang(child); if (lang) return lang; } } else { return findLang(nested); } } } return null; }; return findLang(children); }; // Maps code language to styling variant const mapLangToVariant: Record = { apacheconf: "apacheconf", asm: "asm", bas: "vb", bash: "bash", bashrc: "bash", bat: "batch", batch: "batch", cjs: "js", cmd: "batch", css: "css", csv: "csv", diff: "diff", docker: "docker", gherkin: "gherkin", html: "html", ignore: "ignore", ini: "ini", java: "java", javascript: "js", js: "js", jsx: "js", json: "json", log: "log", makefile: "makefile", markdown: "md", markdownlint_ignore: "json", md: "md", mdx: "md", mjs: "js", none: "none", pascal: "pascal", php: "php", plaintext: "txt", powershell: "powershell", ps1: "powershell", py: "python", python: "python", qmd: "md", sh: "bash", sql: "sql", svg: "svg", toml: "toml", ts: "ts", tsx: "ts", txt: "txt", typescript: "ts", vb: "vb", vba: "vb", vbnet: "vbnet", vbs: "vb", xml: "xml", yaml: "yaml", yml: "yaml", zsh: "bash", }; // Map variant keys to their icon components and CSS classes // For retrieving the value for the iconify key, just go to "https://icon-sets.iconify.design/?query=python" // and, in the top right search area, type the name of the language. // When found, click on the desired icon and retrieve the "icon name" as displayed in the bottom part. interface VariantIcon { iconClassName: string; iconify: string; ariaLabel: string; } const variantIcons: Record = { apacheconf: { iconClassName: styles.apacheconf_icon, iconify: "logos:apache", ariaLabel: "ApacheConf Logo", }, asm: { iconClassName: styles.asm_icon, iconify: "vscode-icons:file-type-assembly", ariaLabel: "Asm Logo", }, bash: { iconClassName: styles.bash_icon, iconify: "devicon:bash", ariaLabel: "Bash Logo", }, batch: { iconClassName: styles.batch_icon, iconify: "file-icons:ms-dos", ariaLabel: "Batch Logo", }, css: { iconClassName: styles.css_icon, iconify: "ph:file-css", ariaLabel: "CSS Logo", }, csv: { iconClassName: styles.csv_icon, iconify: "ph:file-csv", ariaLabel: "CSV Logo", }, diff: { iconClassName: styles.diff_icon, iconify: "ph:git-diff", ariaLabel: "Diff Logo", }, docker: { iconClassName: styles.docker_icon, iconify: "uil:docker", ariaLabel: "Docker Logo", }, docusaurus: { iconClassName: styles.docker_docusaurus, iconify: "logos:docusaurus", ariaLabel: "Docusaurus Logo", }, gherkin: { iconClassName: styles.gherkin_icon, iconify: "skill-icons:gherkin-light", ariaLabel: "Gherkin Logo", }, html: { iconClassName: styles.html_icon, iconify: "ph:file-html", ariaLabel: "HTML Logo", }, ignore: { iconClassName: styles.ignore_icon, iconify: "codicon:sync-ignored", ariaLabel: "Ignore Logo", }, ini: { iconClassName: styles.ini_icon, iconify: "ph:file-ini", ariaLabel: "INI Logo", }, java: { iconClassName: styles.java_icon, iconify: "vscode-icons:file-type-java", ariaLabel: "Java Logo", }, js: { iconClassName: styles.js_icon, iconify: "logos:javascript", ariaLabel: "JS Logo", }, json: { iconClassName: styles.json_icon, iconify: "ix:json-document", ariaLabel: "JSON Logo", }, log: { iconClassName: styles.log_icon, iconify: "ix:log", ariaLabel: "Log Logo", }, makefile: { iconClassName: styles.makefile_icon, iconify: "vscode-icons:file-type-makefile", ariaLabel: "GNU Makefile Logo", }, md: { iconClassName: styles.md_icon, iconify: "ph:markdown-logo", ariaLabel: "Markdown Logo", }, none: { iconClassName: styles.none_icon, iconify: "ph:empty", ariaLabel: "None Logo", }, pascal: { iconClassName: styles.pascal_icon, iconify: "file-icons:pascal", ariaLabel: "Pascal Logo", }, php: { iconClassName: styles.php_icon, iconify: "bi:filetype-php", ariaLabel: "PHP Logo", }, powershell: { iconClassName: styles.powershell_icon, iconify: "file-icons:powershell", ariaLabel: "Powershell Logo", }, python: { iconClassName: styles.python_icon, iconify: "devicon:python", ariaLabel: "Python Logo", }, sql: { iconClassName: styles.sql_icon, iconify: "ph:file-sql", ariaLabel: "SQL Logo", }, svg: { iconClassName: styles.svg_icon, iconify: "ph:file-svg", ariaLabel: "SVG Logo", }, toml: { iconClassName: styles.toml_icon, iconify: "tabler:toml", ariaLabel: "Toml Logo", }, ts: { iconClassName: styles.ts_icon, iconify: "logos:typescript-icon", ariaLabel: "TypeScript Logo", }, txt: { iconClassName: styles.txt, iconify: "ph:file-txt", ariaLabel: "Txt Logo", }, vb: { iconClassName: styles.vb_icon, iconify: "fluent:document-vb-16-regular", ariaLabel: "VB Logo", }, vbnet: { iconClassName: styles.vbnet_icon, iconify: "fluent:document-vb-16-regular", ariaLabel: "VbNet Logo", }, xml: { iconClassName: styles.xml_icon, iconify: "hugeicons:xml-01", ariaLabel: "XML Logo", }, yaml: { iconClassName: styles.yaml_icon, iconify: "devicon-plain:yaml", ariaLabel: "YAML Logo", }, }; // ELI5 line-by-line renderer — used when eli5json prop is present. // Calls Prism.highlight() directly to get syntax-colored HTML per line, // then overlays interactive ? badges for annotated lines. // The tooltip is rendered via a React Portal into document.body so it // escapes every overflow:hidden / stacking-context ancestor. // C0 control characters other than tab/newline/carriage return, plus DEL. A few example files // carry them for real — `make.bat` colours its output with ESC sequences — and a raw control // character in the page is invalid HTML (the SSG minifier flags it on every build, in every // locale). They are shown as their Unicode "control picture" (ESC -> ␛) instead: visible, honest // about what the file contains, and valid markup. const isControlChar = (code: number): boolean => (code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f; function hasControlChars(text: string): boolean { for (let i = 0; i < text.length; i += 1) { if (isControlChar(text.charCodeAt(i))) return true; } return false; } function showControlChars(text: string): string { if (!hasControlChars(text)) return text; let out = ""; for (let i = 0; i < text.length; i += 1) { const code = text.charCodeAt(i); // U+2400..U+241F picture each C0 control; U+2421 pictures DEL. out += isControlChar(code) ? String.fromCharCode(code === 0x7f ? 0x2421 : 0x2400 + code) : text[i]; } return out; } interface Eli5CodeBlockProps { code: string; lang: string; eli5: Record; } // Eli5CodeBlock renders its own
, bypassing Docusaurus's native
//  (see below) and losing its built-in copy button in the
// process — reimplemented locally rather than pulling in
// `@docusaurus/theme-common/internal`'s CodeBlockContext, which TODO 0112
// already names as a suspect in an open React hydration bug.
function useCopyToClipboard() {
  const [copied, setCopied] = useState(false);
  const timeoutRef = useRef(undefined);

  const copy = useCallback((text: string) => {
    navigator.clipboard.writeText(text).then(() => {
      window.clearTimeout(timeoutRef.current);
      setCopied(true);
      timeoutRef.current = window.setTimeout(() => setCopied(false), 1000);
    });
  }, []);

  useEffect(() => () => window.clearTimeout(timeoutRef.current), []);

  return { copied, copy };
}

function Eli5CodeBlock({ code, lang, eli5 }: Eli5CodeBlockProps): JSX.Element {
  const [activeLine, setActiveLine] = useState(null);
  const [tooltipStyle, setTooltipStyle] = useState(null);
  const { copied, copy } = useCopyToClipboard();
  // false until mounted client-side — gates the tooltip's document.body Portal,
  // which doesn't exist during SSR.
  const [mounted, setMounted] = useState(false);
  const badgeRefs = useRef>({});

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount flag, see comment on `mounted` above
    setMounted(true);
  }, []);

  // `Prism.highlight()` + `.split("\n")` (the previous approach) corrupts any
  // token that spans multiple lines — a `/** … */` JSDoc comment most
  // commonly — because the highlighted HTML gets cut across an unclosed
  // ``. Browsers silently repair that malformed HTML while parsing the
  // page, but repair it *differently* than what React's virtual tree expects
  // once it hydrates, which is a genuine structural mismatch (React error
  // #418) on any snippet whose language has multi-line comments/strings —
  // confirmed via a server-vs-hydrated-DOM diff on `docusaurus-cards.mdx`
  // (see TODO 0112). `useTokenize` avoids the string-splitting step entirely:
  // it walks Prism's real token tree and only ever hands back whole tokens,
  // already split at line boundaries.
  const grammar = useMemo(
    () => Prism.languages[lang] || Prism.languages.plaintext || Prism.languages.clike,
    [lang],
  );
  const tokenizedLines = useTokenize({ prism: Prism, code, grammar, language: lang });
  // A trailing `\n` in `code` makes normalizeTokens() append one synthetic
  // last "line" holding a single `{ content: "\n", empty: true }` token —
  // drop it so line numbers keep matching `eli5`'s 1-based keys, exactly like
  // the previous `rawLines.pop()` did for a trailing blank line.
  const lines = useMemo(() => {
    const last = tokenizedLines[tokenizedLines.length - 1];
    if (last?.length === 1 && last[0].empty) {
      return tokenizedLines.slice(0, -1);
    }
    return tokenizedLines;
  }, [tokenizedLines]);

  // Compute position: tooltip appears above the badge, right-aligned.
  const positionTooltip = useCallback((lineNum: string) => {
    const el = badgeRefs.current[lineNum];
    if (!el) return;
    const rect = el.getBoundingClientRect();
    setTooltipStyle({
      top: rect.top, // translateY(-100%-gap) shifts it above
      right: window.innerWidth - rect.right, // right-align with the badge
    });
  }, []);

  const handleBadgeClick = useCallback(
    (lineNum: string) => setActiveLine((prev) => (prev === lineNum ? null : lineNum)),
    [],
  );

  // Keep the tooltip glued to its badge: position is computed once on
  // hover/focus, but `position: fixed` doesn't move with scrolling — without
  // this, scrolling the page (or the horizontally-scrollable code block)
  // while a tooltip is open leaves it stranded at its old screen position.
  useEffect(() => {
    if (!activeLine) return undefined;
    const reposition = () => positionTooltip(activeLine);
    window.addEventListener("scroll", reposition, { capture: true, passive: true });
    window.addEventListener("resize", reposition, { passive: true });
    return () => {
      window.removeEventListener("scroll", reposition, { capture: true });
      window.removeEventListener("resize", reposition);
    };
  }, [activeLine, positionTooltip]);

  const activeExplanation = activeLine ? eli5[activeLine] : null;

  return (
    <>
      
        
        
          {lines.map((lineTokens, i) => {
            const lineNum = String(i + 1);
            const explanation = eli5[lineNum];
            const isActive = activeLine === lineNum;

            return (
              
{lineTokens.map((token, tokenIdx) => ( {/* Display only: the copy button still copies `code`, real bytes and all. */} {showControlChars(token.content)} ))} {explanation ? ( ) : ( )}
); })}
{mounted && activeExplanation && tooltipStyle && createPortal( {activeExplanation} , document.body, )} ); } // ELI5 verbose summary — a Show/Hide disclosure rendered below the code block, // giving a narrative explanation of the whole snippet (as opposed to // Eli5CodeBlock's per-line tooltips). Kept as its own component, deliberately // outside Eli5CodeBlock's render tree — see TODO 0112 (open React hydration // bug on Eli5CodeBlock/): this feature must not add surface area // there. Also kept as a sibling of the collapsible `snippet_content` div // (not nested inside it): nesting would let this block's own open/close // change the *inner* scrollHeight without the outer collapsible's height // (computed once per its own toggle) ever re-measuring, clipping the summary // whenever a reader opens it after the snippet was already expanded. interface Eli5SummaryBlockProps { summary: string; } function Eli5SummaryBlock({ summary }: Eli5SummaryBlockProps): JSX.Element { const [open, setOpen] = useState(false); const contentRef = useRef(null); const [height, setHeight] = useState("0px"); const contentId = `eli5-summary-${useId()}`; useEffect(() => { if (contentRef.current) { setHeight(open ? `${contentRef.current.scrollHeight}px` : "0px"); } }, [open, summary]); return (

{summary}

); } interface Props { filename?: string; title?: ReactNode; code?: string; children?: ReactNode; /** * Force the open/closed state. Leave undefined and the component decides: * open, unless the snippet is longer than COLLAPSE_OVER_LINES. * * Pass `false` explicitly on file-index sections (ProjectSetup articles, * "All Files at a Glance"): there the page is a LIST of files the reader * scans and opens one of, not a narrative the code belongs to. */ defaultOpen?: boolean; variant?: string; lang?: string; eli5json?: string; eli5summary?: string; } /** * Above this many lines a snippet opens PARTIALLY: the first lines are shown * under a fade, with a "show the remaining N lines" control. Below it, the * snippet simply opens. * * 30 is roughly one screenful of code at 0.85rem/1.6 on a laptop — the point * where "I can take this in at a glance" stops being true. */ const COLLAPSE_OVER_LINES = 30; /** Height of the peek window, in lines. Deliberately less than * COLLAPSE_OVER_LINES so a truncated block always LOOKS truncated. */ const PEEK_LINES = 18; export default function Snippet({ filename, title, code, children, defaultOpen, variant, lang: pluginLang, eli5json, eli5summary, }: Props): JSX.Element { const contentRef = useRef(null); // Parse eli5json string (injected by remark-snippet-loader) into an object const eli5 = useMemo((): Record | null => { if (!eli5json || typeof eli5json !== "string") return null; try { return JSON.parse(eli5json); } catch { return null; } }, [eli5json]); const eli5Summary = typeof eli5summary === "string" && eli5summary.trim() ? eli5summary.trim() : null; const lineCount = useMemo( () => (typeof code === "string" ? code.replace(/\n$/, "").split("\n").length : 0), [code], ); const isLong = lineCount > COLLAPSE_OVER_LINES; /* * The default is OPEN. A reader who clicked "Install Docker under WSL2" came * for the compose file; making them click again to see it is asking them to * work for the thing they searched for. * * Three concrete costs of the old closed-by-default, beyond the extra click: * - find-in-page broke. The collapsed content sits in a max-height: 0 / * overflow: hidden box, which Ctrl+F cannot reach: a reader searching the * page for a variable name got "no results" while the word was right * there. * - printing produced empty boxes. * - the ELI5 "Explain this snippet" control is gated on `open`, so the * line-by-line explanations nobody had expanded were never discovered. * * Long files do not get the old all-or-nothing treatment either: they open * truncated (see PEEK_LINES), so the reader sees WHAT it is immediately and * the page is not eaten by 300 lines. */ const [open, setOpen] = useState(defaultOpen ?? true); const [expandedLong, setExpandedLong] = useState(false); const handleToggle = useCallback(() => setOpen((prev) => !prev), []); const handleExpandLong = useCallback(() => setExpandedLong(true), []); const contentId = `snippet-content-${useId()}`; const lang = useMemo(() => { // 1. Check for the `lang` prop provided by your plugin if (pluginLang) return pluginLang; // 2. Fallback to the existing logic for `children` const languageFromChildren = getLanguageFromChildren(children); if (languageFromChildren) return languageFromChildren; // 3. Fallback to the filename extension if no other language is found if (typeof filename === "string") { const ext = filename.split(".").pop(); return mapLangToVariant[ext ?? ""] || "plaintext"; } return "plaintext"; }, [pluginLang, children, filename]); // Resolve `default` markers (TODO 0088) before rendering. `code` // (from `source=`/`code=`, including files loaded at build time) is plain // text feeding Prism, so substitution there is a string swap — no visible // "this is yours" underline, which the `children` path below gets for // free; an acceptable v1 trade-off, see Vars/readme.md. const resolve = useVarResolver(); const resolvedCode = useMemo( () => (code ? substitutePlainText(code, resolve) : code), [code, resolve], ); const resolvedChildren = useMemo( () => substituteChildren(children, resolve, (name, value, key) => ( {value} )), [children, resolve], ); // Use line-by-line ELI5 renderer when annotations are available and code is a string. // Otherwise fall back to Docusaurus CodeBlock (or raw children). // // Code holding control characters also takes the ELI5 renderer, with no annotations: it is the // only one that can show them escaped while its copy button keeps the original bytes. The // native would either print them raw or copy the escaped version. const codeBlock = useMemo(() => { const hasEli5 = Boolean(eli5 && Object.keys(eli5).length > 0); if (resolvedCode && (hasEli5 || hasControlChars(resolvedCode))) { return ; } if (resolvedCode) { return {resolvedCode}; } return resolvedChildren; }, [resolvedCode, eli5, lang, resolvedChildren]); const baseName = useMemo( () => typeof filename === "string" ? (filename.split("/").pop() ?? "").toLowerCase() : null, [filename], ); const isDockerFile = useMemo(() => { if (!baseName) return false; return ( baseName === "dockerfile" || baseName === "docker" || baseName.endsWith(".docker") || baseName.endsWith(".dockerfile") || baseName.endsWith(".dockerignore") || baseName.endsWith("compose.yaml") || baseName.endsWith("compose.yml") || baseName.endsWith("docker-compose.yaml") ); }, [baseName]); const isDocusaurus = useMemo(() => baseName === "docusaurus.config.js", [baseName]); const variantKey = useMemo( () => variant || (isDockerFile ? "docker" : isDocusaurus ? "docusaurus" : mapLangToVariant[lang]) || "none", [variant, isDockerFile, isDocusaurus, lang], ); const variantClass = styles[`variant_${variantKey}`] || ""; const IconInfo = variantIcons[variantKey] || variantIcons.none; const { iconify, ariaLabel } = IconInfo; const displayTitle = title || filename || (lang ? lang.toUpperCase() : translate({ id: "snippet.defaultTitle", message: "Snippet" })); // Peek mode: long, open, and the reader has not asked for the rest yet. const peeking = open && isLong && !expandedLong; return (
{/* Conditional render, not a max-height animation on a permanently mounted div. With the old approach the collapsed code stayed in the DOM inside an overflow: hidden box — invisible to Ctrl+F but still found by a screen reader, which is the worst of both. Unmounting is honest: closed means closed. */} {open && (
{codeBlock}
{peeking && ( )}
)} {open && eli5Summary && }
); } ``` **src/components/Snippet/styles.module.css** ```css /* * Snippet. * * The language brand colours were NOT regenerated: the 33 values below are * exactly the ones already in this file. What changed is how far each one * reaches. Each variant now declares a single --lang-color, consumed by three * things at once: * * 1. the 3px rule under the toolbar, * 2. the language logo — but only the MONOCHROME ones. Iconify sets * ph:*, bi:*, ix:*, file-icons:* etc. render in currentColor, which is * why they used to come out black; logos:*, devicon:* and * vscode-icons:* ship their own brand colours and ignore it. Both end * up correct. Practical consequence: if a logo ever renders grey, it * is a monochrome icon whose .variant_ class is missing. * 3. the focus ring of the disclosure button. * * So the logo stays, in its real brand colour, and is the only saturated thing * in an otherwise sand-coloured toolbar. Languages present in * mapLangToVariant but with no .variant_ class fall back to the neutral sand * defined on [class^="variant_"] — nothing to add per language. */ /* * IMPORTANT — companion change in index.tsx: * * -
* +
* * The root element was carrying Infima's "alert alert--info" utility classes, * which paint the whole card in Docusaurus blue. Same issue as TLDR and * QuickJump. It was invisible on snippets WITH ELI5 annotations, because * .eli5_pre paints its own background over it; it showed on every other * snippet, where the native is transparent and let the blue * through. * * The .snippet_block rule below is defensive against that class coming back * (Infima sets background/color/border on .alert with plain specificity). */ .snippet_block { composes: mk-surface from global; margin: 1.5rem 0; padding: 0; overflow: hidden; background: var(--mk-surface); color: var(--mk-ink); } /* * ── One surface per rendering path ──────────────────────────────────────── * * A Snippet renders through one of THREE paths, and they had three different * looks: * 1. Eli5CodeBlock — its own
, warm dark
 *   2. native   — Prism's light theme
 *   3. raw children        — rendered MDX prose, no 
 at all, no padding
 *
 * Paths 1 and 2 are code: one dark surface, owned by the 
. Path 3 is
 * prose (a .md snippet renders as text, not as source) and had no padding at
 * all. All three now share ONE dark surface, owned by .snippet_inner: keeping
 * the prose path light was tried and rejected — a .md snippet sitting white
 * between two dark ones read as a different component.
 */
.snippet_inner {
  padding: 0.875rem 1.125rem;
  font-family: var(--ifm-font-family-monospace);
  font-size: 0.85rem;
  line-height: 1.6;
  color: var(--mk-code-fg);
  background: var(--mk-code-bg);
}

.snippet_inner > :last-child {
  margin-bottom: 0;
}

/* :has() — when the snippet holds a real code block, the 
 brings its own
 * padding, so the wrapper gives that up. The surface stays here either way. */
.snippet_inner:has(pre) {
  padding: 0;
}

/* Every engine's output goes transparent; the dark ground above shows through,
 * so all three paths land on exactly one colour. */
.snippet_inner :global(.theme-code-block),
.snippet_inner :global(div[class*="codeBlockContainer"]),
.snippet_inner :global(div[class*="codeBlockContent"]),
.snippet_inner :global(pre[class*="codeBlock"]),
.snippet_inner :global(.prism-code),
.snippet_inner pre,
.snippet_inner pre code {
  margin: 0;
  background: transparent !important;
  border-radius: 0;
  box-shadow: none;
}

/* Prism's light theme also sets a dark text colour — unreadable now. */
.snippet_inner :global(.prism-code),
.snippet_inner pre code {
  color: var(--mk-code-fg);
}

/* Docusaurus's own copy / word-wrap buttons, now on a dark ground. */
.snippet_inner :global(div[class*="buttonGroup"]) button {
  color: var(--mk-n-500);
  background: rgb(255, 255, 255, 0.06);
  border-color: rgb(255, 255, 255, 0.15);
}

.snippet_inner :global(div[class*="buttonGroup"]) button:hover {
  color: var(--mk-code-fg);
  background: rgb(255, 255, 255, 0.12);
}

/* The file header is now the shared toolbar — byte-identical to the one
 * ProjectSetup uses, so the two components read as one family. */
.snippet_summary {
  composes: mk-toolbar from global;
  width: 100%;
  cursor: pointer;
  border: none;
  border-bottom: 1px solid var(--mk-border);
  --chevron-rotation: 0deg;
}

.snippet_summary:hover {
  background: var(--mk-n-100);
}

.snippet_summary[aria-expanded="true"] {
  --chevron-rotation: 180deg;
}

.snippet_summary:focus-visible {
  outline: 2px solid var(--lang-color, var(--mk-primary));
  outline-offset: -2px;
}

/* The filename: an eyebrow, like every other component label. It used to be
 * 1rem bold — the weight of a small heading.
 *
 * .filename_wrapper is the class index.tsx actually uses today; .snippet_filename
 * is the name used in the migration notes. Both are defined so the stylesheet
 * works whether or not the markup is renamed. */
.snippet_filename,
.filename_wrapper {
  composes: mk-eyebrow from global;
  display: flex;
  gap: 0.6rem;
  align-items: center;
  min-width: 0;
  overflow: hidden;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chevron {
  margin-left: auto;
  color: var(--mk-n-500);
  transition: transform 0.3s ease;
  transform: rotate(var(--chevron-rotation));
}

.chevron.rotate {
  transform: rotate(180deg);
}

.snippet_content {
  overflow: hidden;
  transition: max-height 0.35s ease;
}

/*
 * Peek mode: a long snippet opens truncated rather than closed.
 *
 * The height is expressed in LINES, not pixels: --peek-lines is set inline by
 * index.tsx and multiplied by the code block's own line-height here, so the
 * window always cuts between two lines instead of through the middle of one.
 */
.snippet_peek {
  position: relative;
  overflow: hidden;
  max-height: calc(var(--peek-lines, 18) * 1.6 * 0.85rem + 1.75rem);
}

/* The fade tells the reader the block continues. It sits above the content but
   below the button, and must not intercept text selection. */
.snippet_peek::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 5.5rem;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent, var(--mk-code-bg) 85%);
}

.snippet_expand {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 1;
  padding: 0.55rem 1rem;
  font-family: var(--ifm-font-family-base);
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--mk-code-fg);
  cursor: pointer;
  background: none;
  border: none;
}

.snippet_expand:hover {
  color: var(--mk-term-prompt);
}

.snippet_expand:focus-visible {
  outline: 2px solid var(--mk-term-prompt);
  outline-offset: -2px;
}

/* A reader who asked for less motion gets the whole file rather than a
   height that would otherwise animate open. */
@media (prefers-reduced-motion: reduce) {
  .snippet_content {
    transition: none;
  }
}

/*
 * PRINT — the reason default-open matters most. A printed article must carry
 * its code: no truncation window, no fade, no control.
 */
@media print {
  .snippet_peek {
    max-height: none;
  }

  .snippet_peek::after,
  .snippet_expand,
  .chevron {
    display: none;
  }
}

.snippet_toolbar {
  display: flex;
  justify-content: flex-end;
  padding: 0.5rem 1rem 0;
}

/* === The language logo ================================================== */

/*
 * 20px, always left, always in its brand colour. The previous rule sized every
 * [class$="_icon"] at 18px with a 0.5rem right margin; spacing now comes from
 * the toolbar's gap, which survives markup edits.
 */
.snippet_logo,
[class$="_icon"] {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  color: var(--lang-color, var(--mk-n-500));
}

/*
 * Light brand colours (JavaScript #f0d91e, Docusaurus #3ecc5f, Batch #8bad2e)
 * float on the sand toolbar. A 12%-black hairline anchors them without
 * repainting the mark — the only concession the logos make to the theme.
 */
.variant_js .snippet_logo,
.variant_docusaurus .snippet_logo,
.variant_batch .snippet_logo {
  filter: drop-shadow(0 0 0.5px rgb(0, 0, 0, 0.45));
}

/* === Variant base + per-language brand colours =========================== */

/*
 * Border colours map to each language/tool's own brand identity (Docker blue,
 * PHP purple, GitHub's linguist palette…) — external identities, not site UI,
 * so they intentionally don't follow the light/dark theme. Legitimate
 * color-no-hex exception, see AGENTS.md and
 * .todos/039-hardcoded-hex-colors-no-token-system.md.
 *
 * Values unchanged from the previous version of this file. The old rule also
 * set `color: #fff` on every variant, which was dead weight: no text in these
 * blocks sits on the brand colour.
 */
/* stylelint-disable color-no-hex */
[class^="variant_"] {
  --lang-color: var(--mk-n-500);
  border-top: 3px solid var(--lang-color);
}

.variant_apacheconf {
  --lang-color: #d22128;
}
.variant_asm {
  --lang-color: #6e4c13;
}
.variant_bash {
  --lang-color: #3eaaaf;
}
.variant_batch {
  --lang-color: #8bad2e;
}
.variant_csv {
  --lang-color: #2d7f5e;
}
.variant_css {
  --lang-color: #1e4fb4;
}
.variant_diff {
  --lang-color: #e36209;
}
.variant_docker {
  --lang-color: #0aa2ce;
}
.variant_docusaurus {
  --lang-color: #3ecc5f;
}
.variant_gherkin {
  --lang-color: #5a9a00;
}
.variant_html {
  --lang-color: #e44d26;
}
.variant_ignore {
  --lang-color: #6a737d;
}
.variant_ini {
  --lang-color: #008080;
}
.variant_java {
  --lang-color: #e11e2b;
}
.variant_js {
  --lang-color: #f0d91e;
}
.variant_json {
  --lang-color: #ad8b00;
}
.variant_log {
  --lang-color: #808080;
}
.variant_makefile {
  --lang-color: #6e4f1e;
}
.variant_md {
  --lang-color: #083fa1;
}
.variant_none {
  --lang-color: #aaaaaa;
}
.variant_pascal {
  --lang-color: #4278b8;
}
.variant_php {
  --lang-color: #7a87b9;
}
.variant_powershell {
  --lang-color: #0078d7;
}
.variant_python {
  --lang-color: #2c628d;
}
.variant_sql {
  --lang-color: #e38c00;
}
.variant_svg {
  --lang-color: #f04e23;
}
.variant_toml {
  --lang-color: #f36d25;
}
.variant_ts {
  --lang-color: #3178c6;
}
.variant_txt {
  --lang-color: #6a737d;
}
.variant_vb {
  --lang-color: #571a66;
}
.variant_vbnet {
  --lang-color: #68217a;
}
.variant_xml {
  --lang-color: #bf3b1b;
}
.variant_yaml {
  --lang-color: #a31416;
}
/* stylelint-enable color-no-hex */

/* ═══════════════════════════════════════════════════════════════════════════
   ELI5 — line-by-line renderer
   Still deliberately dark-optimised regardless of site theme (it sits on a
   Prism background), but now on the palette's own warm black and with the
   badge/tooltip using tokens instead of six rgba() literals.
   ═══════════════════════════════════════════════════════════════════════════ */

.eli5_pre {
  position: relative;
  margin: 0;
  padding: 0.75rem 0;
  overflow-x: auto;
  font-family: var(--ifm-font-family-monospace);
  font-size: var(--ifm-code-font-size, 0.875rem);
  line-height: 1.5;
  color: var(--mk-code-fg);
  background: var(--mk-code-bg);
  border-radius: 0 0 var(--mk-radius-md) var(--mk-radius-md);
}

.eli5_line {
  display: flex;
  align-items: flex-start;
  min-height: 1.5em;
  padding: 0 1rem;
}

.eli5_line:hover {
  background: rgb(255, 255, 255, 0.04);
}

.eli5_code {
  flex: 1;
  overflow: hidden;
  white-space: pre;
}

.eli5_badge_placeholder {
  display: inline-block;
  width: 1.2rem;
  flex-shrink: 0;
  margin-left: 0.5rem;
}

.eli5_badge_wrapper {
  position: relative;
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  margin-left: 0.5rem;
}

/* The "?" badge was indigo-tinted — the one cool accent left in the site.
 * It now uses the savanna green that the Terminal prompt uses. */
.eli5_badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.2rem;
  height: 1.2rem;
  padding: 0;
  font-size: 0.6rem;
  font-weight: 700;
  line-height: 1;
  color: var(--mk-term-prompt);
  cursor: pointer;
  background: rgb(147, 183, 100, 0.14);
  border: 1px solid rgb(147, 183, 100, 0.45);
  border-radius: 50%;
  flex-shrink: 0;
  transition:
    background 0.15s ease,
    color 0.15s ease,
    border-color 0.15s ease;
}

.eli5_badge:hover,
.eli5_badge:focus-visible,
.eli5_badge_active {
  color: var(--mk-code-bg);
  background: var(--mk-term-prompt);
  border-color: var(--mk-term-prompt);
  outline: none;
}

.eli5_copy_button {
  position: absolute;
  top: 0.6rem;
  right: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  padding: 0.35rem;
  color: var(--mk-n-500);
  cursor: pointer;
  background: rgb(255, 255, 255, 0.06);
  border: 1px solid rgb(255, 255, 255, 0.15);
  border-radius: var(--mk-radius-sm);
  opacity: 0;
  transition:
    opacity 0.15s ease-in-out,
    background 0.15s ease-in-out,
    color 0.15s ease-in-out;
}

.eli5_copy_button:hover,
.eli5_copy_button:focus-visible {
  color: var(--mk-code-fg);
  background: rgb(255, 255, 255, 0.12);
  opacity: 1;
  outline: none;
}

/* :where() zeroes specificity so this never outranks the button's own hover
 * state above — see the original note, the reasoning is unchanged. */
/* stylelint-disable-next-line no-descending-specificity */
:where(.eli5_pre:hover) .eli5_copy_button {
  opacity: 0.55;
}

.eli5_copy_icon {
  width: 1.1rem;
  height: 1.1rem;
}

.eli5_tooltip {
  position: fixed;
  z-index: 9999;
  width: 270px;
  max-width: min(270px, 90vw);
  padding: 0.5rem 0.75rem;
  font-family: var(--ifm-font-family-base);
  font-size: 0.8rem;
  line-height: 1.55;
  color: var(--mk-ink);
  white-space: normal;
  pointer-events: none;
  background: var(--mk-surface);
  border: 1px solid var(--mk-border);
  border-radius: var(--mk-radius-md);
  box-shadow: var(--mk-shadow-3);
  transform: translateY(calc(-100% - 8px));
}

.eli5_tooltip::before {
  position: absolute;
  bottom: -5px;
  right: 4px;
  width: 8px;
  height: 8px;
  content: "";
  background: var(--mk-surface);
  border-right: 1px solid var(--mk-border);
  border-bottom: 1px solid var(--mk-border);
  transform: rotate(45deg);
}

.eli5_summary_block {
  border-top: 1px solid var(--mk-border);
}

.eli5_summary_toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 0.4rem 1rem;
  font-size: 0.8rem;
  font-weight: 500;
  color: var(--mk-ink-soft);
  cursor: pointer;
  background: none;
  border: none;
}

.eli5_summary_toggle_right {
  display: inline-flex;
  gap: 0.5rem;
  align-items: center;
}

.eli5_summary_badge {
  composes: mk-eyebrow from global;
  font-size: 0.65rem;
  font-weight: 500;
}

.eli5_summary_toggle:hover {
  background: var(--ifm-hover-overlay);
}

.eli5_summary_toggle:focus-visible {
  outline: 2px solid var(--mk-primary);
  outline-offset: -2px;
}

.eli5_summary_content {
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.eli5_summary_text {
  margin: 0;
  padding: 0 1rem 0.75rem;
  font-size: 0.85rem;
  line-height: 1.6;
  color: var(--mk-ink);
}
```

### 2. Installing `ProjectSetup`

Finally, here's how to install the `ProjectSetup` component itself.

**Project: src/components/ProjectSetup**

> **Note:**
>
> Install dependencies: `npm install jszip`

**src/components/ProjectSetup/index.tsx**

```typescript
import React, {
  useState,
  Children,
  isValidElement,
  useMemo,
  useCallback,
  useRef,
  useEffect,
  type JSX,
  type ReactNode,
} from "react";
import CodeBlock from "@theme/CodeBlock";
import Snippet from "@site/src/components/Snippet";
import Translate, { translate } from "@docusaurus/Translate";
import JSZip from "jszip";
import styles from "./styles.module.css";

/**
 * Guideline Component
 * Minimalist component to capture post-install instructions.
 */
export const Guideline = Object.assign(() => null, { isGuideline: true });

/**
 * EmptyFolder Component
 * Use this to ensure a specific empty directory is created.
 */
export const EmptyFolder = Object.assign(() => null, { isEmptyFolder: true });

interface ChildProps {
  children?: ReactNode;
  filename?: string;
  title?: string;
  name?: string;
  code?: string;
}

interface Props {
  folderName?: string;
  createFolder?: boolean;
  /** Alternative to `children`: a flat map of fileName -> file content. */
  files?: Record;
  children?: ReactNode;
}

// Extract plain text from Guideline children (handles MDX wrapping like 

) const getGuidelineText = (nodes: ReactNode): string => { let text = ""; Children.forEach(nodes, (child) => { if (typeof child === "string" || typeof child === "number") { text += child; } else if (isValidElement(child)) { text += getGuidelineText((child.props as ChildProps).children); } }); return text; }; /** * ProjectSetup Component * * A component that renders a list of files and provides tools to scaffold the project: * - View files with syntax highlighting. * - Generate a Bash "one-liner" to create the folder structure. * - Download the project as a ZIP file. * * @example * * * npm install && npm start * * * */ export default function ProjectSetup({ folderName = "my-project", createFolder = true, files, children, }: Props): JSX.Element { const [showScript, setShowScript] = useState(false); const [expandAll, setExpandAll] = useState(false); const [copied, setCopied] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [zipError, setZipError] = useState(null); const copiedTimerRef = useRef | null>(null); useEffect(() => { return () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); }; }, []); // Parse children once; recalculate only when files or children change const { fileList, guidelines, folders } = useMemo(() => { const fileList: { fileName: string; content: string; originalNode?: React.ReactElement; }[] = []; const guidelines: React.ReactElement[] = []; const folders: string[] = []; if (files) { Object.entries(files).forEach(([fileName, content]) => { fileList.push({ fileName, content }); }); } else if (children) { const processChildren = (nodes: ReactNode) => { Children.forEach(nodes, (child) => { if (!isValidElement(child)) return; const childType = child.type as unknown as { isGuideline?: boolean; isEmptyFolder?: boolean; }; const props = child.props as ChildProps; if (child.type === Guideline || childType.isGuideline) { guidelines.push(child); return; } if (child.type === EmptyFolder || childType.isEmptyFolder) { if (props.name) folders.push(props.name); return; } // Exclude HTML elements (like

) from being treated as files. const isFileComponent = child.type === Snippet || props.filename || (props.title && typeof child.type !== "string"); if (isFileComponent) { const { filename, title, code, children: childChildren } = props; const name = filename || title || "unknown"; // The remark plugin injects the file content into the 'code' prop. // We prioritize 'code'. We do NOT use 'source' as content because it is just the file path. let content = code; if (!content && typeof childChildren === "string") content = childChildren; if (content) fileList.push({ fileName: name, content, originalNode: child }); } else if (props.children) { processChildren(props.children); } }); }; processChildren(children); } return { fileList, guidelines, folders }; }, [files, children]); // Memoize the generated script so it isn't rebuilt on every UI state change const shellScript = useMemo(() => { // ~ is not expanded inside double quotes in bash; replace with $HOME so // paths like ~/.zsh/fns don't create a literal "~" directory. const shellPath = (p: string) => p.replace(/^~/, "$HOME"); const shellFolder = shellPath(folderName); let script = ""; if (createFolder) { script += `mkdir -p "${shellFolder}" && cd "${shellFolder}"\n`; } folders.forEach((f) => { script += `\nmkdir -p "${shellPath(f)}"\n`; }); fileList.forEach(({ fileName, content }) => { const delimiter = "PROJECT_SETUP_EOF"; script += `\nmkdir -p "$(dirname "${fileName}")" && cat <<'${delimiter}' > "${fileName}"\n${content.trim()}\n${delimiter}\n`; }); script += `\necho "✅ Setup completed in folder: ${shellFolder}"`; guidelines.forEach((g) => { const text = getGuidelineText((g.props as ChildProps).children) .trim() .replace(/\s+/g, " "); script += `\necho "🚀 ${text}"`; }); return script; }, [createFolder, folderName, folders, fileList, guidelines]); const handleCopyScript = useCallback(() => { navigator.clipboard.writeText(shellScript); setCopied(true); if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); copiedTimerRef.current = setTimeout(() => setCopied(false), 2000); }, [shellScript]); const downloadZip = useCallback(async () => { if (fileList.length === 0 && folders.length === 0) { setZipError( translate({ id: "projectSetup.zipEmpty", message: "No files or folders found to zip.", }), ); return; } setZipError(null); setIsDownloading(true); try { const zip = new JSZip(); // Remove leading slashes to ensure a valid zip folder path (fixes issues with "/tmp/...") const safeFolderName = folderName.replace(/^\/+/, ""); const folder = zip.folder(safeFolderName); // JSZip.folder() is typed `JSZip | null`; it only returns null on an // invalid name, which `safeFolderName` already guards against. The throw // is caught below and surfaced through setZipError(). if (!folder) { throw new Error(`Could not create the "${safeFolderName}" zip folder.`); } folders.forEach((f) => folder.folder(f)); fileList.forEach(({ fileName, content }) => folder.file(fileName, content.trim())); if (guidelines.length > 0) { let readme = `# Project Setup: ${folderName}\n\n## Next Steps:\n\n`; guidelines.forEach((g) => { const text = getGuidelineText((g.props as ChildProps).children) .trim() .replace(/\s+/g, " "); readme += `- ${text}\n`; }); folder.file("README.md", readme); } const blob = await zip.generateAsync({ type: "blob" }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${safeFolderName}.zip`; // Append to DOM before clicking for Firefox compatibility document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } finally { setIsDownloading(false); } }, [fileList, folders, folderName, guidelines]); return (
📦 Project setup: {folderName}
Help {showScript ? ( ) : ( )}
{zipError && (

{zipError}

)} {showScript ? (

Copy this block to your terminal (Linux/WSL2). {!createFolder && ( {" "} {folderName} }} > {"Make sure you are located in the {folder} folder."} )}

{shellScript}
) : (

{" "} Need help here? Click the "Help" button above for setup instructions.

{fileList.map(({ fileName, content, originalNode }, index) => originalNode ? ( React.cloneElement( originalNode as React.ReactElement<{ code?: string; defaultOpen?: boolean; }>, { code: content, defaultOpen: expandAll, key: `${fileName || index}-${expandAll}`, }, ) ) : ( ), )}
)}
); } ``` **src/components/ProjectSetup/styles.module.css** ```css /* * ProjectSetup — shares Snippet's toolbar and surface exactly. Before, the two * sat one above the other in the same article with different radii, different * header backgrounds and different header type. */ .projectSetupContainer { composes: mk-surface from global; margin: 2rem 0; overflow: hidden; } .header { composes: mk-toolbar from global; } .headerTitle { composes: mk-eyebrow from global; } .content { padding: 1rem 1.25rem; } .scriptContainer { max-height: 400px; overflow: auto; } .tip { margin-bottom: 1rem; } .error { margin-bottom: 0.75rem; font-size: 0.875rem; color: var(--mk-danger-line); } .buttonGroup { composes: mk-toolbar__actions from global; } /* Toolbar actions: quiet by default, terracotta on hover. They used to be * Infima buttons, which put two filled buttons in a header bar. */ .buttonGroup button, .buttonGroup a { font-family: var(--ifm-font-family-monospace); font-size: 0.72rem; font-weight: 600; color: var(--mk-ink-soft); text-decoration: none; cursor: pointer; background: none; border: none; padding: 0.25rem 0.5rem; border-radius: var(--mk-radius-sm); } .buttonGroup button:hover, .buttonGroup a:hover { color: var(--mk-primary); background: var(--ifm-hover-overlay); } .buttonGroup button:focus-visible, .buttonGroup a:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 2px; } ``` ### 3. The project\_setup help page In the ProjectSetup component, there is a help page that provides instructions on how to use the component. You can customize this page by editing the `src/pages/project_setup.mdx` file. Here is mine: **src/pages/project\_setup.mdx** ```mdx --- title: "How to use the Project Setup component" description: "A detailed guide on how to use the Project Setup component, create a ZIP file, or generate an install script." --- import ProjectSetup, { EmptyFolder, Guideline } from "@site/src/components/ProjectSetup"; import Snippet from "@site/src/components/Snippet"; import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import CodeBlock from "@theme/CodeBlock"; import styles from "./project_setup.module.css"; # How to Use the Project Setup Component The **Project Setup** component is a powerful tool designed to help you quickly scaffold a new project. It provides a list of files and offers two main ways to get started: downloading a ZIP archive or using a generated installation script. Below a fully functional example of the component looks like. Now, simply run 'docker compose up -d' to start the project then open your browser and surf to http://localhost:8080 ## Option 1: Download as a ZIP File This is the simplest way to get all the project files on your local machine. 1. **Click the "ZIP" button**: This will bundle all the files and folders into a single `.zip` archive and trigger a download in your browser. 2. **Save the file**: Choose a location on your computer to save the ZIP file. 3. **Uncompress the archive**:
Right-click the downloaded file and select "Extract All...". Choose a destination folder, and the files will be uncompressed there.
Open your terminal, navigate to the directory where you saved the file, and run:
unzip your-project-name.zip -d your-project-name
4. **Read the `README.md`**: Inside the uncompressed folder, you'll find a `README.md` file. It contains the next steps, such as installation commands (e.g., `npm install`) and how to run the project. Below an example of how using the ZIP in an MS-DOS console. Just download the archive, unzip it , use `cd` to jump in the folder where files are located then, finally, display the instructions (`type README.md`) and follow them in order to run the project. In the example below, instructions are to run `docker compose up -d` then to open a webpage. ![Unzip on DOS](./images/unzip_using_dos.webp) ## Option 2: Generate Install Script This option is ideal for users who are comfortable with the command line (Linux, macOS, or WSL on Windows). It generates a "one-liner" script that creates all the files and folders directly in your terminal. 1. **Click the "Generate install script" button**: This will switch the view from the file list to a code block containing a Bash script. 2. **Copy the script**: Click the **"Copy"** button to copy the entire script to your clipboard. 3. **Paste and run in your terminal**: - Open your terminal. - Navigate to the directory where you want to create the project. - Paste the script and press `Enter`. 4. **Follow the on-screen instructions**: The script will create the project folder, all the necessary subdirectories and files, and then print the next steps to follow. Once you've clicked on the `Generate install script` button, you've to click on the `Copy` button to copy it in your clipboard. Start a console (MS-DOS, Powershell or most likely a Linux/WSL one) and paste the content of the clipboard. This done, press Enter. ![Generate install script](./images/generate_install_script.webp) Then start a console (Ubuntu in the example below) and paste: ![Pasting clipboard to WSL](./images/pasting_on_wsl.webp) As you can see on the image, instructions are then displayed at the bottom of the screen. ## Option 3: Manual File Creation If you prefer to create the files yourself, you can do so by following these steps: 1. **Create the project folder**: Manually create the main folder for your project (e.g., `my-project`) or jump in any existing one. 2. **View the file list**: In the **Project Setup** component, you can see a list of all the files and their content. 3. **Create each file**: For each file in the list, create a new file with the same name and copy the content into it. 4. **Create empty folders**: If there are any empty folders specified, make sure to create them as well. 5. **Follow the guidelines**: After creating all the files, follow the instructions provided in the "guidelines" section of the component to install dependencies and run the project. ``` ## Conclusion The `ProjectSetup` component is a powerful tool for sharing project structures in your Docusaurus blog posts. It provides an interactive way for readers to explore the file structure, generates setup scripts, and offers ZIP downloads, making it easier for them to replicate your projects. By using this component, you can enhance the learning experience for your readers and encourage them to engage more deeply with your content. --- # How to indicate AI-assisted content in a Docusaurus blog > A step-by-step guide to show when a blog post has been written with the help of an AI like Google Gemini, by adding an icon and an author. --- ![How to indicate AI-assisted content in a Docusaurus blog](/img/v2/gemini-co-author.webp) **TL;DR** This blog post explains how to indicate AI-assisted content in a Docusaurus blog by adding an icon and a co-author. In the age of powerful AI assistants like Google Gemini, it's becoming increasingly common to use them for content creation, brainstorming, or even coding. As a content creator, I believe in transparency with my audience. If an AI has significantly contributed to a piece of content, the reader should know. *Two concrete cases where I lean on an AI and therefore raise this flag: [Automating TL;DR Summaries with Gemini AI](/blog/gemini-tldr) and [AI-Powered Code Tooltips in Docusaurus](/blog/docusaurus-eli5-snippet-tooltips).* This blog post is a step-by-step guide on how I implemented an "AI Assisted" indicator in my Docusaurus blog. When I add `ai_assisted: true` to my YAML post's front matter, two things happen automatically: 1. A small "AI Assisted" icon appears next to the post's date and reading time. 2. "Google Gemini" is added as a co-author. Here's how you can do the same for your own blog. **Quick Jump:** [See it in Action](#the-result) | [Step 2: Creating the AIIcon Component](#step-2-creating-the-aiicon-component) ## The Result Here's what a single `ai_assisted: true` flag produces, rendered on an actual post of this blog: ![The "AI Assisted" badge and the Google Gemini co-author, rendered on a real post header](./images/ai_assisted_badge.png) The icon sits right next to the date and reading time, and the AI shows up as a real co-author alongside the human one — nothing hidden, nothing to click to reveal. ## Why Do This As a content creator, I believe in transparency with my audience. If an AI has significantly contributed to a piece of content, the reader should know — not buried in a footer, not omitted, right there in the header where the date and reading time already are. A single front-matter flag drives both the icon and the co-author, so there's no separate step to forget. ## Step 1: The `ai_assisted` Front Matter The entire process is driven by a single flag in the front matter of your blog posts. This is the simplest part: just add `ai_assisted: true` to any post where you've had help from an AI. **your-post.md** ```markdown --- slug: my-awesome-post title: My Awesome Post authors: [christophe] ai_assisted: true --- ... content ... ``` This flag will be the trigger for all the logic we'll build next. ## Step 2: Creating the `AIIcon` Component First, let's create the visual component for our indicator. This is a simple React component that displays an icon and the text "AI Assisted." **Project: src/components/Blog/AIIcon** **src/components/Blog/AIIcon/index.tsx** ```typescript import React from "react"; import styles from "./index.module.css"; import Translate, { translate } from "@docusaurus/Translate"; const AIIcon = (): React.JSX.Element => { return (
AI Assisted
); }; export default AIIcon; ``` **src/components/Blog/AIIcon/index.module.css** ```css .aiIcon { display: inline-flex; align-items: center; vertical-align: middle; gap: 8px; border: 1px solid var(--ifm-color-primary-dark); padding: 4px 8px; border-radius: 8px; font-size: 0.8rem; color: var(--ifm-color-primary-dark); margin-left: 4px; } .icon { width: 16px; height: 16px; display: block; } ``` ## Step 3: Displaying the Icon with Swizzling Now we need to make this component appear in our post header. To do this, we need to modify some of Docusaurus's core theme components. This is where **swizzling** comes in. Swizzling is a Docusaurus feature that allows you to replace a theme component with your own customized version. In this implementation, the logic is spread across three components: `BlogPostItem`, its `Header`, and the `Info` section within the header. We'll need to swizzle all three. Run the following commands in your terminal: ```bash $ yarn run swizzle @docusaurus/theme-classic BlogPostItem $ yarn run swizzle @docusaurus/theme-classic BlogPostItem/Header $ yarn run swizzle @docusaurus/theme-classic BlogPostItem/Header/Info ``` This will copy the original theme files into your `src/theme` directory, ready for you to edit. ### 3.1. Modify `src/theme/BlogPostItem/index.js` Here, we'll import our new `AIIcon` and decide if it should be rendered based on the front matter. ```javascript title="src/theme/BlogPostItem/index.js" {1,9,12} import AIIcon from "@site/src/components/Blog/AIIcon"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import BlogPostItemContainer from "@theme/BlogPostItem/Container"; import BlogPostItemContent from "@theme/BlogPostItem/Content"; import BlogPostItemFooter from "@theme/BlogPostItem/Footer"; import BlogPostItemHeader from "@theme/BlogPostItem/Header"; export default function BlogPostItem({ children, className }) { const { metadata, isBlogPostPage } = useBlogPost(); const { frontMatter } = metadata; const containerClassName = useContainerClassName(); const aiIcon = frontMatter.ai_assisted && isBlogPostPage ? : null; return ( {children} ); } ``` ### 3.2. Modify `src/theme/BlogPostItem/Header/index.js` This component just needs to accept the `aiIcon` prop and pass it down to its child. ```javascript title="src/theme/BlogPostItem/Header/index.js" {6,10} import React from 'react'; import BlogPostItemHeaderTitle from '@theme/BlogPostItem/Header/Title'; import BlogPostItemHeaderInfo from '@theme/BlogPostItem/Header/Info'; import BlogPostItemHeaderAuthors from '@theme/BlogPostItem/Header/Authors'; // Add aiIcon to the function signature export default function BlogPostItemHeader({aiIcon}) { return (
); } ``` ### 3.3. Modify `src/theme/BlogPostItem/Header/Info/index.js` Finally, this component receives the prop and renders it. ```javascript title="src/theme/BlogPostItem/Header/Info/index.js" {11,24-25} import React from "react"; import clsx from "clsx"; import { translate } from "@docusaurus/Translate"; // ... other imports function Spacer() { return <>{" · "}; } export default function BlogPostItemHeaderInfo({ className, aiIcon }) { const { metadata } = useBlogPost(); const { date, readingTime } = metadata; // ... other code return (
{typeof readingTime !== "undefined" && ( <> )} {aiIcon && } {aiIcon}
); } ``` With these changes, the "AI Assisted" icon will now appear on any blog post with the correct front matter. ## Step 4: Adding "Google Gemini" as an Author Just like the icon, we can automatically add an author. ### 4.1. Define the AI Author First, define your AI author in `blog/authors.yml`. **blog/authors.yml** ```yaml gemini: name: Google Gemini title: AI Assistant url: https://gemini.google.com/ image_url: /img/gemini-logo.webp ``` ### 4.2. Swizzle the Authors Component We need to swizzle one more component to inject the author logic. ```bash $ yarn run swizzle @docusaurus/theme-classic BlogPostItem/Header/Authors ``` ### 4.3. Modify `src/theme/BlogPostItem/Header/Authors/index.js` Now, edit the newly created file to add the logic. We read the `ai_assisted` flag from the front matter and, if it's true, we push our "Gemini" author object into the list of authors. ```javascript title="src/theme/BlogPostItem/Header/Authors/index.js" {14-24} import React from "react"; import clsx from "clsx"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import BlogAuthor from "@theme/Blog/Components/Author"; import styles from "./styles.module.css"; export default function BlogPostItemHeaderAuthors({ className }) { const { metadata: { authors }, frontMatter, assets, } = useBlogPost(); const allAuthors = [...authors]; if (frontMatter.ai_assisted) { const geminiAuthor = { name: "Google Gemini", title: "AI Assistant", url: "https://gemini.google.com/", imageURL: "/img/gemini-logo.webp", }; if (!allAuthors.find((a) => a.name === geminiAuthor.name)) { allAuthors.push(geminiAuthor); } } const authorsCount = allAuthors.length; // ... rest of the component ``` ## Conclusion That's it! With a few component customizations, you now have a robust system for transparently indicating AI assistance in your blog. This approach keeps your Markdown files clean (just one flag to add) and centralizes the logic within your Docusaurus theme, making it easy to manage and update. By being open about your process, you can build greater trust with your audience. This `ai_assisted` flag is what you'll see on other posts where AI played a real role, like [How I used Google Gemini Nano Banana on my blog](/blog/gemini-meerkat) (generating all my banner images) or [Lovable.dev](/blog/lovable-dev-ai) (an AI-driven app builder) — the very post the screenshot above was taken from. --- # Bring Your CLI Tutorials to Life with a Typewriter Terminal > Add a typewriter animation to your Docusaurus Terminal component — command lines typed character-by-character, output lines appearing in sequence, with a blinking cursor and a click-to-skip escape hatch. Three new props, zero breaking changes. --- ![Bring Your CLI Tutorials to Life with a Typewriter Terminal](/img/v2/typewriter_terminal.webp) **TL;DR** Static code blocks explain what to run. An animated terminal shows it *happening*. This article extends the existing `Terminal` component with a `typewriter` prop: command lines (those starting with `$` or `#`) are typed character-by-character with a blinking cursor; output lines appear whole after a short pause. Three new props control speed and pacing. The change is fully backward-compatible — every existing `` block stays identical unless you opt in. Implementation is pure React state + `setTimeout`, no library dependency. CLI tutorials are dense. A reader follows along, copy-pasting commands, hoping the output they see matches what you wrote. But when the article just shows a static block of text, the sequence of events — the command, the pause, the response — collapses into a single wall of monospace. A typewriter animation restores that sequence. The command appears letter by letter, as if someone is actually typing it. Then the output follows. The reader's eye is guided through the interaction naturally, without any extra explanation. **Quick Jump:** [The new typewriter prop](#the-new-typewriter-prop) | [Create the component](#create-the-component) If you're a regular reader of my blog, you know that I've developed a component called `Terminal` that lets me list a series of commands I've run in a Linux-style console—and it looks pretty realistic. It's the console counterpart of [the component I use for showing code snippets](/blog/docusaurus-snippets). Here's an example: ```bash $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` However, it's a little lacking in "life"; let's see how we can make it look pretty cool with a typewriter-style animation. The Terminal output here above has been obtained using this block in my Markdown content: ```jsx $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` Docusaurus will then do the rendering during the preview of this post or during the HTML generation. ## The new `typewriter` prop Add `typewriter` to opt into the animation: ```jsx $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` Here is what happens, frame by frame: 1. `$` appears, then `d`, then `o`, then `c`… the command types itself out character by character 2. A blinking green `▋` cursor tracks the current position 3. Once the command is fully typed, a brief pause lets it settle 4. `[+] Running 3/3` appears as a complete line 5. Each output line follows in sequence 6. The cursor disappears when the animation ends > **Skip the animation:** > > Click anywhere on the terminal to skip to the end. Useful for readers who have already seen the animation and just want to copy the commands. ```bash $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` Another example: ```bash $ npm install added 347 packages, and audited 348 packages in 12s 42 packages are looking for funding run "npm fund for details found 0 vulnerabilities ``` ```bash $ npm run build ✓ frontend@1.0.0 build ✓ vite build vite v6.2.1 building for production... ✓ 124 modules transformed. dist/index.html 0.58 kB dist/assets/index-8af3d2.js 215.43 kB ✓ built in 2.14s $ npm run preview ✓ frontend@1.0.0 preview ✓ vite preview ➜ Local: http://localhost:4173/ ➜ Network: http://192.168.1.42:4173/ ``` ```bash $ git status On branch feature/authentication Changes not staged for commit: modified: src/auth/login.ts modified: src/auth/session.ts $ git add . $ git commit -m "feat(auth): add session refresh" [feature/authentication 7c1f2ab] feat(auth): add session refresh 2 files changed, 48 insertions(+), 12 deletions(-) $ git push origin feature/authentication Enumerating objects: 12, done. Counting objects: 100% (12/12), done. Writing objects: 100% (8/8), 1.24 KiB | 1.24 MiB/s, done. Total 8 (delta 4), reused 0 (delta 0) To github.com:company/project.git e1a9c2f..7c1f2ab feature/authentication -> feature/authentication ``` ```bash $ docker compose up -d [+] Running 4/4 ✔ Network api_default Created ✔ Container api-db-1 Started ✔ Container api-redis-1 Started ✔ Container api-backend-1 Started $ docker compose ps NAME STATUS PORTS api-db-1 Up 3 seconds 5432/tcp api-redis-1 Up 3 seconds 6379/tcp api-backend-1 Up 2 seconds 0.0.0.0:8080->8080/tcp $ docker compose logs backend --tail=10 backend-1 | Connecting to PostgreSQL... backend-1 | Database connection established backend-1 | Redis connection established backend-1 | HTTP server listening on :8080 ``` ```bash $ apt update Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease Get:2 http://security.ubuntu.com/ubuntu noble-security InRelease [126 kB] Fetched 126 kB in 1s $ apt upgrade -y Reading package lists... Building dependency tree... Calculating upgrade... 12 upgraded, 0 newly installed, 0 to remove. $ systemctl restart nginx $ systemctl status nginx ● nginx.service - nginx web server Loaded: loaded Active: active (running) $ df -h Filesystem Size Used Avail Use% /dev/sda1 80G 41G 36G 54% ``` ## Animation starts only when visible A terminal buried at the bottom of a long article would finish its animation before the reader ever reached it. The component solves this with the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API): the typewriter effect starts only when at least 10 % of the terminal enters the viewport. Scroll past it and the animation greets you exactly when you arrive. > **Tip:** > > Want to check this? Simply reload this page (`CTRL`+`F5`) and just press End to jump to the conclusion of this post. Then, scroll up. Animations will start. Just because the Terminal component is now in the visible part of the page. ## Speed scales automatically with line count Two additional props let you fine-tune pacing, but you rarely need them — the component auto-scales based on the number of lines: | Lines | `typewriterSpeed` | `typewriterLineDelay` | Approx. total duration | | ----- | ----------------- | --------------------- | ---------------------- | | ≤ 5 | 40 ms/char | 400 ms/line | \~2 s | | 6–10 | 25 ms/char | 200 ms/line | \~2.5 s | | 11–20 | 20 ms/char | 150 ms/line | \~3.5 s | | > 20 | 12 ms/char | 100 ms/line | \~3.5 s | A 30-line session completes in roughly the same time as a 3-line one. The animation stays engaging without becoming a waiting game. Override only when you need a specific feel — a slow, deliberate introduction to a new tool: ```jsx $ fzf --version 0.54.3 (brew) ``` ## How the lines are classified The animation distinguishes two types of lines by looking at the first non-whitespace characters: * **Command lines** — start with `$` or `#`. Typed character-by-character at `typewriterSpeed` ms/char. * **Output lines** — everything else. Revealed as a complete line after `typewriterLineDelay` ms. * **Blank lines** — appear after an 80 ms pause, preserving the visual breathing room of the original content. This means you don't need to annotate your content in any special way. Write the terminal session naturally; the component infers the structure. ## Zero breaking changes Every existing `` block in the codebase continues to render exactly as before. The `typewriter` prop defaults to `false`, so the static behavior is the default. If you want to enable the animation everywhere in one step, a global search-and-replace on ` c + 1)` after `typewriterSpeed` ms * Otherwise (output line, or command fully typed) → schedule the move to the next line after `typewriterLineDelay` ms When `lineIdx` reaches the end of the lines array, `animDone` is set to `true` and the component switches back to rendering the original `{children}` React node — so any syntax highlighting or rich markup in the children is preserved after the animation completes. The blinking cursor is a single CSS animation: ```css @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .cursor { display: inline-block; color: #00ff00; animation: blink 1s step-end infinite; } ``` No external library. No canvas. Just state and timers. ## Create the component You'll need to create three new files to your own Docusaurus site. The fourth file is optional and this is the component's documentation: **Project: /your\_docusaurus\_site** **src/components/Terminal/index.tsx** ```typescript import { Fragment, useState, useEffect, useCallback, useMemo, useRef, type JSX, type ReactElement, type ReactNode, type SVGProps, } from "react"; import { useMDXComponents } from "@mdx-js/react"; import clsx from "clsx"; import { useVarResolver } from "@site/src/components/Vars/store"; import { substituteChildren, substitutePlainText, } from "@site/src/components/Vars/substitute"; import VarToken from "@site/src/components/Vars/VarToken"; import Icon from "./icon.svg"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // headingPrefixMap is built inside the component (via useMDXComponents) and // passed here so both copy and animation recover the "# " prefix that MDX strips. // `separator` joins *sibling* array items — "\n" by default, because a bare // array of Terminal children is one source line per item (see the heading // comment below). `substituteChildren` (TODO 0088) wraps the fragments of a // single line it split — e.g. "--name " / a VarToken / " -p " — in a // Fragment, and that one case needs "" instead: switched right below, when // recursing into a Fragment's own children, so a substituted value never // gets a spurious line break glued to it (it did — see git history). type HeadingPrefixMap = Map; function getCopyText( children: ReactNode, headingPrefixMap: HeadingPrefixMap, separator = "\n", ): string { let text = ""; if (Array.isArray(children)) { children.forEach((child, index) => { text += getCopyText(child, headingPrefixMap); if (index < children.length - 1) { text += separator; } }); } else if (typeof children === "string") { text += children; } else if (typeof children === "object" && children !== null) { const el = children as ReactElement; const elProps = el.props as { children?: ReactNode }; const prefix = headingPrefixMap?.get(el.type) ?? ""; if (elProps.children) { const childSeparator = el.type === Fragment ? "" : "\n"; text += prefix + getCopyText(elProps.children, headingPrefixMap, childSeparator); } } return text; } // Auto-scales speed based on line count so long terminals don't drag on. // Explicit props always take priority over these defaults. function autoSpeed(lineCount: number) { if (lineCount <= 5) return { speed: 40, delay: 400 }; if (lineCount <= 10) return { speed: 25, delay: 200 }; if (lineCount <= 20) return { speed: 20, delay: 150 }; return { speed: 12, delay: 100 }; } const CopyIcon = (props: SVGProps) => ( ); const CheckIcon = (props: SVGProps) => ( ); interface Props { children: ReactNode; title?: string; wrap?: boolean; /** Enables typewriter animation. Command lines ($/#) typed char-by-char; output lines appear whole. Click to skip. */ typewriter?: boolean; /** ms per character on command lines. Omit to auto-scale based on line count. */ typewriterSpeed?: number; /** ms before each output line appears. Omit to auto-scale based on line count. */ typewriterLineDelay?: number; /** When true, the copy button copies only command lines ($ / #), with the prompt prefix stripped, so the result is directly pasteable. */ copyCommandOnly?: boolean; } export default function Terminal({ children, title, wrap = true, typewriter = false, typewriterSpeed, typewriterLineDelay, copyCommandOnly = false, }: Props): JSX.Element { const resolve = useVarResolver(); const displayTitle = substitutePlainText( title || "user@machine: ~/yourproject", resolve, ); const containerRef = useRef(null); const [copied, setCopied] = useState(false); // Docusaurus registers h1–h6 as anonymous arrow functions in the MDX components // map, so children.type is a function reference, not the string "h1". Build a // Map keyed by those exact references so getCopyText can recover the "# " prefix. const mdxComponents = useMDXComponents(); const headingPrefixMap = useMemo(() => { const map = new Map(); ["h1", "h2", "h3", "h4", "h5", "h6"].forEach((tag, i) => { const prefix = "#".repeat(i + 1) + " "; if (mdxComponents[tag]) map.set(mdxComponents[tag], prefix); map.set(tag, prefix); // fallback for native HTML headings }); return map; }, [mdxComponents]); // Resolve any `default` marker (TODO 0088) against the reader's // current values before anything else touches `children` — copy, the // typewriter animation, and the plain render all read the resolved tree // from here on, so a substitution can never accidentally skip one of them. const resolvedChildren = useMemo( () => substituteChildren(children, resolve, (name, value, key) => ( {value} )), [children, resolve], ); // Extract lines once from children text content. Deliberately captured only // once per typewriter run, not kept live: if the reader edits a value while // the animation is still typing, the in-flight animation finishes with the // text it started with — resolvedChildren (used once animDone, and by copy) // always reflects the latest edit. const animLines = useMemo( () => (typewriter ? getCopyText(resolvedChildren, headingPrefixMap).split("\n") : []), // eslint-disable-next-line react-hooks/exhaustive-deps [typewriter], ); // Effective speed: explicit prop wins; otherwise auto-scale to line count const { speed: effectiveSpeed, delay: effectiveDelay } = useMemo(() => { const auto = autoSpeed(animLines.length); return { speed: typewriterSpeed ?? auto.speed, delay: typewriterLineDelay ?? auto.delay, }; }, [animLines.length, typewriterSpeed, typewriterLineDelay]); // Start animation only when the terminal scrolls into view const [hasBeenVisible, setHasBeenVisible] = useState(false); useEffect(() => { if (!typewriter) return; const el = containerRef.current; if (!el || typeof IntersectionObserver === "undefined") { // Fallback for environments without IntersectionObserver (SSR, old browsers) setHasBeenVisible(true); return; } const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setHasBeenVisible(true); observer.disconnect(); } }, { threshold: 0.1 }, ); observer.observe(el); return () => observer.disconnect(); }, [typewriter]); const [revealedLines, setRevealedLines] = useState([]); const [lineIdx, setLineIdx] = useState(0); const [charIdx, setCharIdx] = useState(0); const [animDone, setAnimDone] = useState(false); // Animation tick — only runs once the component is visible useEffect(() => { if (!typewriter || !hasBeenVisible || animDone) return; if (lineIdx >= animLines.length) { // eslint-disable-next-line react-hooks/set-state-in-effect -- terminal step of a setTimeout-driven typewriter animation, not derivable at render time setAnimDone(true); return; } const line = animLines[lineIdx]; const isCommand = /^\s*[$#]/.test(line); if (isCommand && charIdx < line.length) { const t = setTimeout(() => setCharIdx((c) => c + 1), effectiveSpeed); return () => clearTimeout(t); } // Line complete — pause then advance const pause = line.trim() === "" ? 80 : effectiveDelay; const t = setTimeout(() => { setRevealedLines((prev) => [...prev, line]); setLineIdx((l) => l + 1); setCharIdx(0); }, pause); return () => clearTimeout(t); }, [ typewriter, hasBeenVisible, animDone, lineIdx, charIdx, animLines, effectiveSpeed, effectiveDelay, ]); const skipAnimation = useCallback(() => { if (!typewriter || animDone) return; setRevealedLines(animLines); setLineIdx(animLines.length); setAnimDone(true); }, [typewriter, animDone, animLines]); const handleCopy = useCallback(async () => { let textToCopy = getCopyText(resolvedChildren, headingPrefixMap); if (copyCommandOnly) { textToCopy = textToCopy .split("\n") .filter((line) => /^\s*[$#]/.test(line)) .map((line) => line.replace(/^\s*[$#] ?/, "")) .join("\n"); } try { await navigator.clipboard.writeText(textToCopy); setCopied(true); } catch (err) { console.error("Failed to copy text: ", err); } }, [resolvedChildren, headingPrefixMap, copyCommandOnly]); useEffect(() => { if (copied) { const timer = setTimeout(() => setCopied(false), 2000); return () => clearTimeout(timer); } }, [copied]); const animDisplay = useMemo(() => { if (!typewriter || animDone) return null; const partial = animLines[lineIdx]?.slice(0, charIdx) ?? ""; return [...revealedLines, partial].join("\n"); }, [typewriter, animDone, revealedLines, lineIdx, charIdx, animLines]); const isAnimating = typewriter && !animDone; return (
{displayTitle}
        {isAnimating ? (
          <>
            {/*
             * Ghost: renders the real children invisibly so the 
 immediately
             * occupies its final height. Prevents page reflow on every typed line.
             */}
            
            {/* Animated text overlaid on the ghost */}
            
              {animDisplay}
              
            
          
        ) : (
          resolvedChildren
        )}
      
); } ``` **src/components/Terminal/icon.svg** ```svg > ``` **src/components/Terminal/styles.module.css** ```css /* * Terminal — stays a real terminal: dark in both themes, macOS traffic lights, * prompt and cursor. That is deliberate and unchanged. * * What changed: * - Three hardcoded "Fira Code" declarations and one bare `monospace` → * var(--ifm-font-family-monospace). Changing the site's code face is now * one line, not five. * - #1e1e1e / #2d2d2d (neutral grey-black) → the warm --mk-term-* shell, so * the terminal belongs to the same world as the rest of the page. * - #00ff00 for the prompt and cursor → --mk-term-prompt, the savanna green. * Pure green on near-black is the single harshest colour pair on the site * and it appeared in every article. * - The macOS dots keep their meaning but are desaturated to the palette. * - The header title is an eyebrow, matching Snippet's toolbar exactly. * * Every colour below is a token: no stylelint-disable needed any more. */ .terminal { position: relative; margin: 1.5rem 0; overflow-x: auto; font-family: var(--ifm-font-family-monospace); color: var(--mk-code-fg); background: var(--mk-term-bg); border: 1px solid var(--mk-term-border); border-radius: var(--mk-radius-md); box-shadow: var(--mk-shadow-1); } .terminal_header { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.875rem; background: var(--mk-term-head); border-bottom: 1px solid var(--mk-term-border); border-radius: var(--mk-radius-md) var(--mk-radius-md) 0 0; } .terminal_left { display: flex; gap: 0.5rem; align-items: center; overflow: hidden; white-space: nowrap; } .terminal_icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--mk-n-500); } .terminal_title { composes: mk-eyebrow from global; overflow: hidden; color: var(--mk-n-500); text-overflow: ellipsis; } .terminal_controls { display: flex; gap: 5px; } .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; } .red { background: var(--mk-term-dot-red); } .yellow { background: var(--mk-term-dot-amber); } .green { background: var(--mk-term-dot-green); } .terminal_body { position: relative; margin: 0; padding: 0.875rem 1rem; font-family: var(--ifm-font-family-monospace); font-size: 0.85rem; line-height: 1.6; white-space: pre-wrap; word-break: break-all; background: var(--mk-term-bg); } .terminal_line { display: flex; gap: 0.5rem; } .first_line { margin-bottom: 0.5rem; } .prompt { flex-shrink: 0; color: var(--mk-term-prompt); user-select: none; pointer-events: none; } .terminal_line span { display: inline-block; min-width: 0; } .no_wrap { overflow-x: auto; white-space: pre; word-break: normal; } /* Copy button — unchanged behaviour (fades in on container hover), retokenised. */ .copyButton { position: absolute; top: 2.75rem; right: 0.5rem; z-index: 10; padding: 0.35rem; color: var(--mk-n-500); cursor: pointer; background: transparent; border: none; border-radius: var(--mk-radius-sm); opacity: 0; transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; } .copyButton:hover { color: var(--mk-code-fg); background: rgb(255, 255, 255, 0.08); } .copyButton:focus-visible { opacity: 1; outline: 2px solid var(--mk-term-prompt); outline-offset: 2px; } .terminal:hover .copyButton { opacity: 1; } .copyButtonSvg { width: 1rem; height: 1rem; fill: currentcolor; } .terminal a { color: var(--mk-term-prompt); text-decoration: underline; text-underline-offset: 2px; } .terminal_ghost { visibility: hidden; user-select: none; pointer-events: none; } .terminal_overlay { position: absolute; inset: 0; padding: inherit; font-family: inherit; color: var(--mk-code-fg); white-space: inherit; word-break: inherit; } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .cursor { display: inline-block; font-family: var(--ifm-font-family-monospace); color: var(--mk-term-prompt); animation: blink 1s step-end infinite; } @media (prefers-reduced-motion: reduce) { .cursor { animation: none; } } /* MDX turns "# foo" inside into

; reset so it reads as a shell * comment rather than a chapter heading. */ .terminal_body :is(h1, h2, h3, h4, h5, h6) { margin: 0; padding: 0; font-size: inherit; font-weight: inherit; line-height: inherit; letter-spacing: inherit; color: var(--mk-term-comment); border: none; } .terminal_body h1::before { content: "# "; } .terminal_body h2::before { content: "## "; } .terminal_body h3::before { content: "### "; } .terminal_body h4::before { content: "#### "; } .terminal_body h5::before { content: "##### "; } .terminal_body h6::before { content: "###### "; } .terminal_body p { margin: 0; padding: 0; } ``` **src/components/Terminal/readme.md** ````markdown # 🖥️ Terminal Component A stylized terminal emulator UI for Docusaurus pages. Ideal for showcasing CLI commands, installation steps, or shell output in a visually engaging format. ## ✨ Features - 🧑‍💻 Linux-style header with icon and control dots - 📝 Customizable terminal title - 💻 Styled code block for terminal content - 🎨 Theme-friendly layout using scoped CSS - ⌨️ Optional typewriter animation — command lines typed char-by-char, output appears whole ## Example Out-of-the-box, here is how the component will looks like: ![Example](sample.png) ## 📁 Location This component lives at `src/components/Terminal/index.tsx`. ## 🚀 Usage ```jsx import Terminal from "@site/src/components/Terminal"; npm install npm run build; ``` If no title is provided, the default is: `christophe@home: ~` ### With Word Wrapping Disabled ```jsx $ echo "This is a very long line that should not wrap unless explicitly allowed" ``` ## 🛠 Props | Prop | Type | Required | Default | Description | | --------------------- | ----------------- | -------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `children` | `React.ReactNode` | ✅* | — | Terminal content to display. Not needed when `source` is used (plugin injects it automatically). | | `source` | string | ❌ | — | Path to an external `.txt` file relative to the article (e.g. `./files/terminal-1.txt`). The `remark-snippet-loader` plugin reads the file at build time and injects its content as children. Use this to keep long terminal outputs out of the MDX file. | | `title` | string | ❌ | `user@machine: ~/yourproject` | Optional terminal title shown in the header | | `wrap` | boolean | ❌ | `true` | Enables word wrapping in the terminal body. Set to `false` to disable it. | | `typewriter` | boolean | ❌ | `false` | Enables typewriter animation. Lines starting with `$` or `#` are typed char-by-char; output lines appear whole. Click the terminal to skip. Animation starts only when the terminal scrolls into view. | | `typewriterSpeed` | number | ❌ | auto | ms per character on command lines. Omit to auto-scale: ≤5 lines→40, ≤10→25, ≤20→20, >20→12. | | `typewriterLineDelay` | number | ❌ | auto | ms before each output line appears. Omit to auto-scale: ≤5 lines→400, ≤10→200, ≤20→150, >20→100. | \* `children` is required when `source` is not provided. ### Loading content from a file (`source`) For long terminal outputs (typically > 5 lines), you can store the content in a separate `.txt` file and reference it with `source`. The `remark-snippet-loader` plugin injects the file content at build time — no runtime fetch involved. ```jsx ``` The file `./files/terminal-1.txt` is co-located with the article (same `files/` subfolder used by `Snippet`). Its content is plain text, exactly as it would appear inline: ```text $ docker compose up --detach [+] Running 2/2 ✔ Network demo_default Created ✔ Container counter Started ``` All other props (`typewriter`, `title`, `wrap`, etc.) work exactly the same. ### Typewriter mode example ```jsx {`$ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started`} ``` Speed is auto-scaled from line count by default — no need to set `typewriterSpeed` or `typewriterLineDelay` manually. Override only when you want a specific pacing: ```jsx {`$ fzf --version 0.54.3`} ``` ## 💡 Command Detection Lines starting with `$` or `>` are treated as commands and rendered with a prompt symbol (`>`). This helps visually distinguish commands from output. ## 🎨 Styling This component uses scoped CSS via `styles.module.css`. Key classes include: - `.terminal` — outer container - `.terminal_header` — header bar - `.terminal_left` — icon + title - `.terminal_controls` — control dots - `.terminal_body` — code block area - `.terminal_line` — individual line - `.prompt` — command prompt symbol - `.no_wrap` — disables word wrapping - `.dot.red`, `.dot.yellow`, `.dot.green` — control dot colors Customize these styles to match your site's design system. ## ⚠️ Escaping $ When writing shell commands inside JSX, be mindful that the `$` symbol is interpreted by JavaScript as the start of a template literal (e.g., `${variable}`). To prevent this and ensure your command renders correctly in the terminal, you’ll need to escape `$` signs using a backslash (`\`). For example: ```jsx {`$ docker run --detach --name step_1_2 -p 81:80 -v $(pwd):/var/www/html -u \${UID}:\${GID} php:8.1.5-apache`} ``` This ensures that `\${UID}` and `\${GID}` are treated as literal strings rather than JavaScript expressions. Without escaping, your code may throw an error or render incorrectly. ## 📄 License MIT — free to use and modify. ## 💬 AI generated This code has been generated by Christophe Avonture using Google Gemini and Claude Code. ```` Then edit (or create) the `src/theme/MDXComponents.js` file. If the file already exists, just add the highlighted lines below. If not yet present, create the file with the content below: **src/theme/MDXComponents.js** ```javascript // At the top of your file, add this line: // highlight-next-line import Terminal from "@site/src/components/Terminal"; // Then in your export section, add this line too: export default { // Reusing the default mapping ...MDXComponents, // [...] // highlight-next-line Terminal }; ``` From now one, you can use the `` component in your posts. Enjoy! *That `src/theme/MDXComponents.js` file is the central registry of every custom component of your blog; [Change how Docusaurus will create img tags](/blog/docusaurus-override-img) shows how it can also override the built-in tags, not just add new ones.* --- # Meerkat Mischief: Sprinkling Easter Eggs Across my Blog > A tour of eight small, discoverable easter eggs added to this Docusaurus blog — a hidden ASCII-art meerkat in the page source, a Konami-code sprite run, a console.log wink, a tab-away favicon swap, rotating 404 messages, a hidden sitemap comment and more — plus the real bugs (key-repeat, an AZERTY keyboard mismatch, a wrong emoji, a postBuild race condition, a blurry favicon) caught along the way. --- ![Meerkat Mischief: Sprinkling Easter Eggs Across my Blog](/img/v2/easter_eggs.webp)**TL;DR**Beyond the meerkat mascot already visible on this blog, this blog hides eight small, deliberately discoverable easter eggs: an ASCII-art meerkat sitting right in the page source, a `console.log` wink, rotating 404 messages, a Konami-code sprite run, a tab-away title/favicon swap, an `X-Powered-By` HTTP header, a print-stylesheet footer, and a hidden comment in `sitemap.xml`. This article walks through every one of them with the actual source code, and — more usefully — the real bugs we shipped and caught along the way: a key-repeat race in the Konami handler, an AZERTY keyboard mismatch a real user (not our tests) found, a wrong emoji codepoint, a Docusaurus `postBuild` concurrency gotcha, and a favicon that was unreadable until we redesigned the crop.You already know this site has a mascot: a meerkat that shows up on the 404 page, rides the "scroll to top" button, and hides in an [ASCII-art HTML comment](/blog/docusaurus-ascii-art) on every page. That last one got me thinking: if a comment in the page source is fun, what else could a curious visitor stumble upon?So, this week-end, I asked myself — and [Claude Code](/blog/claude-ia-spare-tokens) — a simple question: how far can we take this without it becoming annoying? The answer turned into eight small easter eggs — the ASCII-art comment revisited in detail, plus seven new additions — each following the same rule: **if you're not looking for it, you'll never see it.** No pop-ups, no confetti on page load, nothing that gets in a reader's way. Just quiet rewards for the curious.> **The one rule behind all of this:** > > Every easter egg on this list lives in the console, the source, the response headers, or a state the visitor has to trigger on purpose (leaving the tab, printing a page, typing a secret code). None of them ever appear in the normal reading flow.## 1. The one that started it all: an ASCII-art meerkat in the page sourceThis is the easter egg that inspired everything else on this list, so it earns the top spot. Press `CTRL`+`U` on any page of this blog — or right-click and choose "View Page Source" — and right after the opening `` line, before a single visible pixel has been described, you'll find a meerkat drawn entirely in ASCII characters, sitting inside an HTML comment:> Screenshot — view-source:https\://www\.avonture.be/blog/ripgrep/ > > ![CTRL-U - View source](/img/v2/suricate_ctrl_u.webp)It's invisible in the rendered page — an HTML comment never reaches the screen — and it doesn't cost a single byte of layout or a single line of visible markup. It's purely a reward for anyone curious enough to look at the raw response.Under the hood, a small `postBuild` plugin (`plugins/ascii-injector/index.mjs`) walks every generated HTML file after `yarn docusaurus build` finishes and inserts the contents of `src/data/banner.txt` as a comment right after the `` tag. *The plugin itself is built step by step in [Inject ASCII Art in any HTML pages rendered by Docusaurus](/blog/docusaurus-ascii-art), and the banner comes from the same generator as the one in [Bash - ASCII art](/blog/bash-ascii-art):*```bash $ yarn docusaurus build $ grep -A2 doctype build/index.html "; if (xml.includes(comment)) { return; } const urlsetTag = /]*>/i; if (!urlsetTag.test(xml)) { console.warn("[sitemap-easter-egg] tag not found, skipping."); return; } const updated = xml.replace(urlsetTag, (m) => `${m}${comment}`); await fs.writeFile(sitemapPath, updated, "utf8"); console.log("[sitemap-easter-egg] Comment injected into sitemap.xml"); }, }; } ```Registered the same way as the [ASCII-art injector](/blog/docusaurus-ascii-art):**docusaurus.config.js (excerpt)**```javascript // [...] plugins: [ // [...] [ "./plugins/ascii-injector/index.mjs", { bannerPath: "src/data/banner.txt" }, ], "./plugins/sitemap-easter-egg/index.mjs", ], ```## What we ended up with**The eight easter eggs**- **ASCII-art meerkat** — hidden in the page source, right after `` - **Console.log wink** — a styled message for anyone with DevTools open - **Rotating 404 messages** — five random, mascot-flavored variants - **Konami code** — `↑ ↑ ↓ ↓ ← → ← → B A` sends a meerkat running across the screen - **Tab-away swap** — title and favicon change while the tab is hidden, and revert on return - **X-Powered-By header** — a wink in the HTTP response headers - **Print stylesheet** — a small footer note on printed pages - **Sitemap.xml comment** — a hidden, XML-valid comment as the first child of ``None of these change how a single reader reads a single article. That was the whole point — a blog's job is still to be read, not to perform tricks at the reader. But for the developers, the crawlers, the DevTools-curious, and the people who actually print articles or inspect response headers, there's now a little more personality hiding just under the surface. So cool no?That list has since grown by one: every egg above assumes a keyboard or an open DevTools panel, which is no use to anyone reading on a phone — so [shaking the phone](/blog/docusaurus-shake-easter-egg) now startles the mascot full-screen for a couple of seconds, no keyboard involved.If you're running your own Docusaurus blog and have a mascot, a logo, or even just a favorite color: pick one or two of these — they're all small, self-contained, and none of them require touching your actual content. Start with the one that matches where your readers already are (DevTools console if your audience is developers, HTTP headers if it's other bloggers who poke at things, print CSS if people print your recipes or tutorials) and grow from there. --- # AI-Powered Code Tooltips in Docusaurus — Explain Like I'm Five > Add hover tooltips to tricky code lines in your Docusaurus blog — powered by Claude, generated at build time, zero browser latency. Covers the remark plugin, the React renderer, and the CLI script. --- ![AI-Powered Code Tooltips in Docusaurus — Explain Like I'm Five](/img/v2/ai_snippets.webp) **TL;DR** Your Dockerfile is crystal-clear to you. To a junior reader, `RUN npm ci --omit=dev` is gibberish. This article adds a `?` badge to the tricky lines of your `` code blocks. Hovering reveals a plain-English explanation generated once by Claude and stored in a JSON file — no API key in the browser, no hover latency. The whole system has three parts: a Node.js script that calls the Claude API and writes a `.eli5.json` file, a one-line extension to your existing `remark-snippet-loader` plugin that auto-injects the annotations, and a custom React renderer inside the `Snippet` component that overlays the badges. The author workflow is: run the script, commit the JSON, done. When you write a tutorial aimed at developers who are learning, there is always a gap between what you assume they know and what they actually know. You could add inline explanations — but that bloats the article. You could add a glossary — but readers skip it. What you really want is optional, on-demand context that appears exactly where the reader is confused. That is what this feature does. A small `?` badge floats at the right edge of annotated lines. Hover it, and a tooltip explains the line in simple English. Move away, and the code is clean again. ## What it looks like ```plaintext ┌─ Dockerfile ─────────────────────────────────────────── ▾ ─┐ │ 1 FROM node:20-alpine [?] │ │ 2 │ │ 3 WORKDIR /app │ │ 4 COPY package*.json ./ [?] │ │ 5 RUN npm ci --omit=dev [?] │ │ 6 │ │ 7 COPY . . │ │ 8 EXPOSE 3000 [?] │ │ 9 CMD ["node", "server.js"] [?] │ └────────────────────────────────────────────────────────────┘ ↓ hover [?] on line 5 ┌──────────────────────────────────────────────────────────────┐ │ npm ci installs exactly what's in package-lock.json. │ │ --omit=dev skips dev tools — keeps your image smaller. │ └──────────────────────────────────────────────────────────────┘ ``` The `?` badges appear only on lines that Claude decided are non-trivial. Blank lines, closing braces, and self-evident assignments are skipped automatically. The same idea works in the terminal: [`ai-explain`](/blog/ollama-ai-explain) pipes a script or an error message to a local Ollama model and prints a plain-English explanation. ## Architecture The system has three moving parts that work independently: ```plaintext ① scripts/generate-eli5.mjs (run once, by the author) ↓ calls Claude API ↓ writes blog/2026-xx-xx-my-post/files/Dockerfile.eli5.json ↓ commit that file ② plugins/remark-snippet-loader (runs at build time, automatically) ↓ reads Dockerfile ↓ finds Dockerfile.eli5.json alongside it ↓ injects eli5json="..." prop into ③ src/components/Snippet/index.tsx (runs in the browser) ↓ parses eli5json prop ↓ renders Prism-highlighted code line by line ↓ overlays ? badges with tooltips ``` The key design decision is that **AI calls happen at authoring time, not at read time** — the very same pattern I used to [generate my TL;DR summaries with Gemini](/blog/gemini-tldr). The `.eli5.json` files are committed to git and bundled with the site like any other static asset. Readers never wait for an API call, and your API key never leaves your machine. ## Step 1 — The annotation script Create `scripts/generate-eli5.mjs`. This script reads a source file, sends it to Claude with a prompt that asks for line-by-line explanations, and writes the result as a JSON file alongside the source. **scripts/generate-eli5.mjs** ```mjs #!/usr/bin/env node /** * Generate "Explain Like I'm Five" annotations for a source file. * * Usage: * node scripts/generate-eli5.mjs [--force] [--output ] [--locale ] * node scripts/generate-eli5.mjs --locale fr --articles ... [--dry-run] * * Reads ANTHROPIC_API_KEY from process.env or a .env file at the project root — unless * --backend ollama is passed, which calls the local model instead and needs no key. * Writes .eli5.json alongside the source file (or to --output path). * * With --locale (TODO 0121), the output language changes but THE INPUT DOES NOT: an ELI5 * describes code, and the code is identical in every locale. There is no "generate from the * translated article" here — that is the shape of the sibling question index (TODO 0120), * which describes prose. The localized sidecar is written next to the code it explains, as * `.eli5..json`, because `files/` is never duplicated under `i18n/` * (plugins/remark-i18n-assets). * * --articles is the batch `translate` calls once an article is translated: it asks * `scripts/lib/i18n-eligibility.mjs` which of that article's snippets are eligible, and never * re-derives the three conditions itself. With --dry-run it constructs no client and spends * nothing, which is what makes the cost quotable before the confirmation prompt. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { createRequire } from "module"; import { hashSource } from "./lib/eli5-hash.mjs"; import { cmd } from "./lib/cheatsheet-hint.mjs"; import { DEFAULT_BACKEND, ELI5_BACKENDS, callEli5Backend, modelFor, printCostNotice, resolveBackend, } from "./lib/eli5-backend.mjs"; import { ELI5_COST_PER_FILE, articleKeyOf, eli5Candidates, } from "./lib/i18n-eligibility.mjs"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // Load .env from project root (best-effort) try { const dotenv = require("dotenv"); // quiet: dotenv's banner goes to stdout, which would corrupt the single "\t" // line that --porcelain promises to the shell. dotenv.config({ path: path.join(projectRoot, ".env"), quiet: true }); } catch { // dotenv not available — rely on environment variables already set } /** * Escapes the backslashes that do not start a valid JSON escape sequence. * * A snippet whose CODE contains a regex makes the model quote that regex back inside its * explanation, and `~* \.(?:css|js)$` carries a `\.` — a perfectly good regex, an invalid JSON * escape, "Bad escaped character in JSON". It cost the nginx.conf annotation of * `running-docusaurus-with-docker` two full runs before the cause was clear, because the * fenced-response fallback looks like it should already have handled a malformed answer. * * Safe to run over the whole document: in JSON a backslash may only ever appear inside a string, * so any backslash that is not part of `\" \\ \/ \b \f \n \r \t \uXXXX` is a literal the model * failed to escape. Valid escapes are matched first and handed back untouched, `\u` included — * doubling those would corrupt the text this is meant to save. */ function repairLoneBackslashes(text) { return text.replace(/\\(u[0-9a-fA-F]{4}|["\\/bfnrt])|\\/g, (whole, valid) => valid ? whole : "\\\\", ); } // Language detection — mirrors the mapping in remark-snippet-loader const EXT_TO_LANG = { js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", ts: "typescript", tsx: "typescript", py: "python", php: "php", sh: "bash", bash: "bash", zsh: "bash", css: "css", html: "html", json: "json", yml: "yaml", yaml: "yaml", md: "markdown", mdx: "markdown", dockerfile: "docker", ini: "ini", sql: "sql", xml: "xml", toml: "toml", env: "bash", txt: "plaintext", ps1: "powershell", bat: "batch", cmd: "batch", }; const BASE_NAME_TO_LANG = { dockerfile: "docker", ".env": "bash", ".gitignore": "plaintext", ".dockerignore": "plaintext", makefile: "makefile", }; function detectLang(filePath) { const baseName = path.basename(filePath).toLowerCase(); if (BASE_NAME_TO_LANG[baseName]) return BASE_NAME_TO_LANG[baseName]; const ext = path.extname(filePath).slice(1).toLowerCase(); return EXT_TO_LANG[ext] || ext || "plaintext"; } // Output language per locale. `en` carries no extra instruction — it is what the prompt below // already asks for, and 798 existing sidecars were generated without one. const LANGUAGES = { en: { name: "English", extra: "" }, fr: { name: "French", extra: ` Write the "summary" and every value in "explanations" in FRENCH. Keep code identifiers, file names, commands, flags and option names exactly as they appear in the source — translate the prose around them, never the code itself. Use the vouvoiement-free, direct technical register of French developer documentation.`, }, }; const systemPromptFor = ( locale, ) => `You are a patient senior developer explaining code to a junior colleague. Given a source file with numbered lines, produce two things: 1. "summary": two to four plain sentences narrating what this snippet does as a whole and why, the way you'd explain it out loud to someone reading over your shoulder — not a recap of individual lines. No jargon unless immediately defined. 2. "explanations": for each line a junior developer might find confusing, one or two plain sentences. Think "explain like I'm five but I'm also a developer". Skip blank lines, closing braces/brackets, import lines that are self-evident, comments that already explain themselves, trivially obvious assignments. Return ONLY a valid JSON object shaped exactly like: {"summary": "...", "explanations": {"3": "...", "7": "..."}} Keys in "explanations" are line numbers as strings. Omit lines that need no explanation. No markdown, no code fences.${LANGUAGES[locale].extra}`; /** Where a locale's sidecar lives. `en` keeps the historical unsuffixed name. */ function sidecarPath(absSource, locale) { return locale === "en" ? `${absSource}.eli5.json` : `${absSource}.eli5.${locale}.json`; } async function generateEli5( sourceFile, { force = false, outputPath = null, locale = "en", backend = DEFAULT_BACKEND, quoteCost = true, } = {}, ) { if (!LANGUAGES[locale]) { throw new Error( `Unsupported locale "${locale}" (known: ${Object.keys(LANGUAGES).join(", ")}).`, ); } const absSource = path.resolve(sourceFile); if (!fs.existsSync(absSource)) { throw new Error(`Source file not found: ${absSource}`); } if (fs.statSync(absSource).isDirectory()) { throw new Error( `"${sourceFile}" is a directory.\n` + ` To process an entire directory, use the bulk script instead:\n` + ` ${cmd("eli5:bulk", "--dir", sourceFile)}`, ); } const destPath = outputPath || sidecarPath(absSource, locale); if (!force && fs.existsSync(destPath)) { console.log(`⏭ Skipped (already exists): ${path.relative(projectRoot, destPath)}`); console.log(` Use --force to regenerate.`); return { skipped: true }; } const code = fs.readFileSync(absSource, "utf-8"); const lang = detectLang(absSource); const lines = code.split("\n"); // Number the lines for the prompt const numberedCode = lines.map((line, i) => `${i + 1}: ${line}`).join("\n"); const maxTokens = lines.length > 50 ? 2048 : 1024; const model = modelFor(backend); // `quoteCost`: the batch modes below announce the whole run's price once, before the first // call — repeating "1 file × $0.01" on every iteration would bury it. if (quoteCost) printCostNotice(backend, 1); console.log( `🤖 Calling ${model} for ${path.basename(absSource)} (${lines.length} lines, lang: ${lang}` + `${locale === "en" ? "" : `, text: ${locale}`})...`, ); const raw = await callEli5Backend({ backend, systemPrompt: systemPromptFor(locale), userContent: `Language: ${lang}\n\n${numberedCode}`, maxTokens, }); // Parse JSON — strip markdown fences if the model wrapped it anyway, then repair the one // malformation it produces on its own (see repairLoneBackslashes). Both backends go through // this: a grammar-constrained local answer is not trusted any more than a paid one. const candidates = [raw]; const fenced = raw.match(/\{[\s\S]*\}/); if (fenced) candidates.push(fenced[0]); candidates.push(...candidates.map(repairLoneBackslashes)); let parsed; for (const candidate of candidates) { try { parsed = JSON.parse(candidate); break; } catch { // Try the next shape; the throw below reports the original text, never the repaired one. } } if (!parsed) { throw new Error(`Could not parse the ${model} response as JSON:\n${raw}`); } const summary = typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : null; // Validate: only keep string values with string-number keys const explanations = parsed.explanations && typeof parsed.explanations === "object" ? parsed.explanations : {}; const cleaned = {}; for (const [k, v] of Object.entries(explanations)) { if (/^\d+$/.test(k) && typeof v === "string" && v.trim()) { const lineIdx = parseInt(k, 10) - 1; if (lineIdx >= 0 && lineIdx < lines.length) { cleaned[k] = v.trim(); } } } const result = { version: 1, // The model that actually wrote this file — not a constant. Reading a sidecar is how you // tell an Haiku annotation from one produced locally while iterating on the prompt. model, generated: new Date().toISOString(), source: path.basename(absSource), sourceHash: hashSource(code), lang, // `lang` is the language of the CODE (yaml, bash); `textLang` the language of the prose. // Reusing one key for both senses is the contresens TODO 0121 called out up front. textLang: locale, summary, explanations: cleaned, }; fs.writeFileSync(destPath, JSON.stringify(result, null, 2) + "\n"); console.log( `✅ Written: ${path.relative(projectRoot, destPath)}\n ${Object.keys(cleaned).length} annotations on ${lines.length} lines, summary: ${summary ? "yes" : "no"}.`, ); return { skipped: false, destPath, count: Object.keys(cleaned).length }; } /** * Generate the localized sidecars of one or more ARTICLES' snippets. * * What is eligible is decided entirely by `scripts/lib/i18n-eligibility.mjs` — article really * translated, English sidecar present, localized one missing or stale. Nothing here re-tests * any of that, which is why `--force` has no meaning in this mode: a file the module returned * is one that needs writing, and a file it withheld must not be paid for again. That is what * makes `translate --force` free of ELI5 charges when the code did not change. */ async function runArticleBatch({ locale, articlePaths, dryRun, porcelain, assumeTranslated, backend = DEFAULT_BACKEND, }) { if (articlePaths.length === 0) { throw new Error("--articles needs at least one article path."); } const articleKeys = []; for (const candidate of articlePaths) { const key = articleKeyOf(projectRoot, candidate); if (!key) { // Not fatal: `translate` may legitimately hand over a path outside blog/ (a page, say), // and refusing the whole batch over it would block the articles that are fine. if (!porcelain) { console.warn(`⚠ Not inside an article folder, ignored: ${candidate}`); } continue; } articleKeys.push(key); } const { eligible } = eli5Candidates(projectRoot, locale, { articleKeys, assumeTranslated, }); const cost = eligible.length * ELI5_COST_PER_FILE; if (porcelain) { // One line, two fields — the contract translation.sh reads. console.log(`${eligible.length}\t${cost.toFixed(4)}`); return; } if (eligible.length === 0) { console.log(`✅ No ELI5 to generate for ${locale} — every snippet is up to date.`); return; } if (dryRun) { console.log( `🔍 ${eligible.length} ELI5 sidecar(s) would be generated for "${locale}" ≈ $${cost.toFixed(2)}:`, ); eligible.forEach((f) => console.log(` ${path.relative(projectRoot, f)}`)); console.log("\nDry run — nothing written, no API call made."); return; } // Announced once, for the whole run, before the first call — not per file. printCostNotice(backend, eligible.length); let ok = 0; let failed = 0; for (const [index, source] of eligible.entries()) { const relative = path.relative(projectRoot, source); // A plain line, not a trailing `... ` write: generateEli5 logs lines of its own, which // would otherwise land mid-sentence. console.log(` [${index + 1}/${eligible.length}] ${relative}`); try { // force: the eligibility module already decided; a stale localized sidecar must be // overwritten, and generateEli5's own "file exists" guard would otherwise skip it. await generateEli5(source, { locale, force: true, backend, quoteCost: false }); ok++; } catch (err) { // Soft-fail, like the plugin's own ELI5 handling: a missing annotation degrades to the // English one, it never costs the reader the article. console.log(`❌ ${err.message}`); failed++; } } console.log(`\n🧠 ELI5 ${locale}: ${ok} generated, ${failed} failed.`); if (failed > 0) { console.log( ` Those snippets keep showing their English annotation until regenerated.`, ); } } // ── CLI ────────────────────────────────────────────────────────────────────── // Guard: only run CLI logic when this file is the entry point, not when imported. const { fileURLToPath: _fileURLToPath } = await import("url"); const _isMain = process.argv[1] === _fileURLToPath(import.meta.url); if (!_isMain) { /* exported as module — skip CLI */ } else { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log(` Usage: node scripts/generate-eli5.mjs [options] node scripts/generate-eli5.mjs --locale fr --articles ... [options] Options: --force Regenerate even if the sidecar already exists --backend Who writes the annotation: claude (default, paid, ~$0.01 per file) or ollama (local, free, no API key) — known: ${ELI5_BACKENDS.join(", ")} --locale Write the explanations in that language, to .eli5..json (known: ${Object.keys(LANGUAGES).join(", ")}) --articles Treat the positional arguments as ARTICLES, and generate every eligible snippet sidecar under them (requires --locale) --output Write to a custom path instead of the default sidecar name --dry-run With --articles: report what would be generated, and its cost, without constructing the API client — spends nothing --porcelain With --articles: print "\\t" only, for scripts --assume-translated With --articles: count the snippets of articles that are ABOUT to be translated, for quoting a price before the translation runs --help, -h Show this help Examples: node scripts/generate-eli5.mjs blog/2026-01-01-my-post/Dockerfile node scripts/generate-eli5.mjs blog/2026-01-01-my-post/compose.yaml --force node scripts/generate-eli5.mjs src/my-file.sh --output docs/my-file.sh.eli5.json node scripts/generate-eli5.mjs --locale fr --articles blog/2026/09/17/docling --dry-run Requires ANTHROPIC_API_KEY in your environment or in a .env file at the project root (except for --dry-run and --backend ollama, which never call the API). `); process.exit(0); } const force = args.includes("--force"); const dryRun = args.includes("--dry-run"); let backend; try { backend = resolveBackend(args); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); } const porcelain = args.includes("--porcelain"); const outputIdx = args.indexOf("--output"); const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null; const localeIdx = args.indexOf("--locale"); const locale = localeIdx !== -1 ? args[localeIdx + 1] : "en"; if (localeIdx !== -1 && !LANGUAGES[locale]) { console.error( `Error: unsupported --locale "${locale}" (known: ${Object.keys(LANGUAGES).join(", ")}).`, ); process.exit(1); } // Values consumed by a preceding flag are not positional arguments. Miss one here and // `eli5 --backend ollama` runs against a source file named "ollama". const valueIndexes = new Set( [outputIdx, localeIdx, args.indexOf("--backend")] .filter((i) => i !== -1) .map((i) => i + 1), ); const positionals = args.filter((a, i) => !a.startsWith("--") && !valueIndexes.has(i)); if (args.includes("--articles")) { if (locale === "en") { console.error("Error: --articles requires --locale (e.g. --locale fr)."); console.error(" The English sidecars are generated by the bulk script instead:"); console.error(` ${cmd("eli5:bulk")}`); process.exit(1); } runArticleBatch({ locale, articlePaths: positionals, dryRun, porcelain, assumeTranslated: args.includes("--assume-translated"), backend, }).catch((err) => { console.error("❌ Error:", err.message); process.exit(1); }); } else { const sourceFile = positionals[0]; if (!sourceFile) { console.error("Error: no source file specified."); console.error("Run with --help for usage."); process.exit(1); } generateEli5(sourceFile, { force, outputPath, locale, backend }).catch((err) => { console.error("❌ Error:", err.message); process.exit(1); }); } } // end isMain guard ``` A few details worth noting: * **Language detection** mirrors the `remark-snippet-loader` mapping, so the two components always agree on what language a file is. * **The prompt** tells Claude to skip trivial lines and return only a JSON object. The `system` message instructs it to output pure JSON; the temperature is kept at a low value for consistent, factual explanations. * **JSON cleaning** validates that all keys are digit strings in range and all values are non-empty strings, so a malformed response from Claude cannot crash the build. * **Incremental generation**: if the `.eli5.json` already exists, the script exits without calling the API. Use `--force` to regenerate. ### Usage ```bash # Generate for a single file node scripts/generate-eli5.mjs blog/2026-01-01-my-post/files/Dockerfile # Regenerate after the source file changed node scripts/generate-eli5.mjs blog/2026-01-01-my-post/files/compose.yaml --force # Write to a custom path node scripts/generate-eli5.mjs src/some-file.sh --output docs/files/some-file.sh.eli5.json ``` You can also add a convenience alias to `package.json`: ```json "scripts": { "eli5": "node scripts/generate-eli5.mjs", "eli5:bulk": "node scripts/bulk-eli5.mjs" } ``` Then the call becomes `yarn eli5 blog/2026-01-01-my-post/files/Dockerfile`. ### The output format The script writes a file like this: ```json { "version": 1, "model": "claude-haiku-4-5-20251001", "generated": "2026-06-13T10:00:00.000Z", "source": "Dockerfile", "lang": "docker", "explanations": { "1": "FROM picks the base image — the pre-built foundation your image starts from. node:20-alpine is Node 20 on the tiny Alpine Linux OS.", "4": "COPY package*.json ./ copies only the dependency manifest first so Docker can cache the npm install step separately from your application code.", "5": "npm ci is like npm install but strictly follows package-lock.json. --omit=dev leaves out development-only tools, shrinking the final image.", "8": "EXPOSE documents which port the container listens on. It is a label, not a firewall rule — docker run -p actually opens the port.", "9": "CMD is the default command when the container starts. Square-bracket form (exec form) runs node directly without a shell in between." } } ``` Only annotated lines appear. The consumer (the remark plugin and the React component) treats missing lines as having no badge. ## Step 2 — The batch script When you have many posts, running the generator file by file is tedious. `scripts/bulk-eli5.mjs` scans your entire `blog/` directory, finds every `` reference, resolves the path, and generates the `.eli5.json` in one pass. **scripts/bulk-eli5.mjs** ```mjs #!/usr/bin/env node /** * Batch "Explain Like I'm Five" generator. * * Scans all MDX/MD files under --dir (default: blog/) for usages, * resolves each source file, and generates a .eli5.json annotation file alongside it. * Already-existing .eli5.json files are skipped unless --force is passed. * * Usage: * node scripts/bulk-eli5.mjs [--force] [--dir blog/] [--dry-run] [--backend ollama] * * Requires ANTHROPIC_API_KEY in your environment or in a .env file at the project root — * unless --backend ollama is passed, which calls the local model instead and needs no key. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { createRequire } from "module"; import { hashSource } from "./lib/eli5-hash.mjs"; import { DEFAULT_BACKEND, ELI5_BACKENDS, callEli5Backend, modelFor, printCostNotice, resolveBackend, } from "./lib/eli5-backend.mjs"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // Load .env from project root (best-effort) try { const dotenv = require("dotenv"); dotenv.config({ path: path.join(projectRoot, ".env") }); } catch { // rely on environment variables already set } // --- Language detection (same as generate-eli5.mjs) --- const EXT_TO_LANG = { js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", ts: "typescript", tsx: "typescript", py: "python", php: "php", sh: "bash", bash: "bash", zsh: "bash", css: "css", html: "html", json: "json", yml: "yaml", yaml: "yaml", md: "markdown", mdx: "markdown", dockerfile: "docker", ini: "ini", sql: "sql", xml: "xml", toml: "toml", env: "bash", txt: "plaintext", ps1: "powershell", bat: "batch", cmd: "batch", }; const BASE_NAME_TO_LANG = { dockerfile: "docker", ".env": "bash", ".gitignore": "plaintext", ".dockerignore": "plaintext", makefile: "makefile", }; function detectLang(filePath) { const baseName = path.basename(filePath).toLowerCase(); if (BASE_NAME_TO_LANG[baseName]) return BASE_NAME_TO_LANG[baseName]; const ext = path.extname(filePath).slice(1).toLowerCase(); return EXT_TO_LANG[ext] || ext || "plaintext"; } const SYSTEM_PROMPT = `You are a patient senior developer explaining code to a junior colleague. Given a source file with numbered lines, produce two things: 1. "summary": two to four plain-English sentences narrating what this snippet does as a whole and why, the way you'd explain it out loud to someone reading over your shoulder — not a recap of individual lines. No jargon unless immediately defined. 2. "explanations": for each line a junior developer might find confusing, one or two plain-English sentences. Think "explain like I'm five but I'm also a developer". Skip blank lines, closing braces/brackets, import lines that are self-evident, comments that already explain themselves, trivially obvious assignments. Return ONLY a valid JSON object shaped exactly like: {"summary": "...", "explanations": {"3": "...", "7": "..."}} Keys in "explanations" are line numbers as strings. Omit lines that need no explanation. No markdown, no code fences.`; async function generateForFile( absSource, { force = false, backend = DEFAULT_BACKEND } = {}, ) { const destPath = absSource + ".eli5.json"; if (!force && fs.existsSync(destPath)) { return { status: "skipped", path: destPath }; } const code = fs.readFileSync(absSource, "utf-8"); const lang = detectLang(absSource); const lines = code.split("\n"); const numberedCode = lines.map((l, i) => `${i + 1}: ${l}`).join("\n"); const model = modelFor(backend); const raw = await callEli5Backend({ backend, systemPrompt: SYSTEM_PROMPT, userContent: `Language: ${lang}\n\n${numberedCode}`, maxTokens: lines.length > 50 ? 2048 : 1024, }); let parsed; try { parsed = JSON.parse(raw); } catch { const match = raw.match(/\{[\s\S]*\}/); if (match) parsed = JSON.parse(match[0]); else throw new Error(`Invalid JSON from ${model} for ${path.basename(absSource)}`); } const summary = typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : null; // Validate const explanations = parsed.explanations && typeof parsed.explanations === "object" ? parsed.explanations : {}; const cleaned = {}; for (const [k, v] of Object.entries(explanations)) { if (/^\d+$/.test(k) && typeof v === "string" && v.trim()) { const idx = parseInt(k, 10) - 1; if (idx >= 0 && idx < lines.length) cleaned[k] = v.trim(); } } const result = { version: 1, // The model that actually wrote this file — see scripts/lib/eli5-backend.mjs. model, generated: new Date().toISOString(), source: path.basename(absSource), sourceHash: hashSource(code), lang, summary, explanations: cleaned, }; fs.writeFileSync(destPath, JSON.stringify(result, null, 2) + "\n"); return { status: "generated", path: destPath, count: Object.keys(cleaned).length }; } // --- Find all MDX/MD files recursively --- function findMarkdownFiles(dir) { const results = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...findMarkdownFiles(full)); } else if (entry.isFile() && /\.(mdx?|md)$/.test(entry.name)) { results.push(full); } } return results; } // --- Extract source paths from usages --- const SNIPPET_SOURCE_RE = /]*\ssource="([^"]+)"/g; function extractSourcePaths(mdxFile) { const content = fs.readFileSync(mdxFile, "utf-8"); const dir = path.dirname(mdxFile); const sources = []; let match; while ((match = SNIPPET_SOURCE_RE.exec(content)) !== null) { const srcAttr = match[1]; let absPath; if (srcAttr.startsWith("./") || srcAttr.startsWith("../")) { absPath = path.resolve(dir, srcAttr); } else { absPath = path.resolve(projectRoot, srcAttr); } sources.push(absPath); } return sources; } // --- CLI --- const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { console.log(` Usage: node scripts/bulk-eli5.mjs [options] Options: --force Regenerate even if .eli5.json already exists --backend Who writes the annotations: claude (default, paid, ~$0.01 per file) or ollama (local, free, no API key) — known: ${ELI5_BACKENDS.join(", ")} --dir Directory to scan (default: blog/) --dry-run Show what would be generated without calling the API --help, -h Show this help Examples: node scripts/bulk-eli5.mjs node scripts/bulk-eli5.mjs --force node scripts/bulk-eli5.mjs --dir blog/2026 --dry-run node scripts/bulk-eli5.mjs --dir blog/2026 --backend ollama --force Iterate locally, publish with Claude: --backend ollama is there to try a prompt change on a slice of the corpus without spending anything; the published sidecars stay Haiku-generated. Requires ANTHROPIC_API_KEY in your environment or in a .env file at the project root (except for --dry-run and --backend ollama, which never call the API). `); process.exit(0); } const force = args.includes("--force"); const dryRun = args.includes("--dry-run"); let backend; try { backend = resolveBackend(args); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); } const dirIdx = args.indexOf("--dir"); const scanDir = dirIdx !== -1 ? path.resolve(projectRoot, args[dirIdx + 1]) : path.resolve(projectRoot, "blog"); if (!fs.existsSync(scanDir)) { console.error(`Error: directory not found: ${scanDir}`); process.exit(1); } console.log(`🔍 Scanning ${path.relative(projectRoot, scanDir)} for Snippet usages...`); const mdxFiles = findMarkdownFiles(scanDir); const sourceMap = new Map(); // absPath → Set of mdx files referencing it for (const mdxFile of mdxFiles) { for (const src of extractSourcePaths(mdxFile)) { if (!sourceMap.has(src)) sourceMap.set(src, new Set()); sourceMap.get(src).add(path.relative(projectRoot, mdxFile)); } } if (sourceMap.size === 0) { console.log('No usages found.'); process.exit(0); } console.log( `Found ${sourceMap.size} unique source file(s) across ${mdxFiles.length} posts.\n`, ); if (dryRun) { for (const [src, refs] of sourceMap) { const relSrc = path.relative(projectRoot, src); const exists = fs.existsSync(src + ".eli5.json"); const action = exists && !force ? "SKIP" : "GENERATE"; console.log(` [${action}] ${relSrc}`); for (const ref of refs) console.log(` ← ${ref}`); } console.log("\nDry run — nothing written."); process.exit(0); } // What this run will really call for: the sources that exist and are not being skipped. // Counting the map would quote a price for the ⏭ files too. const toGenerate = [...sourceMap.keys()].filter( (src) => fs.existsSync(src) && (force || !fs.existsSync(src + ".eli5.json")), ).length; printCostNotice(backend, toGenerate); let generated = 0, skipped = 0, errors = 0; for (const [src] of sourceMap) { const relSrc = path.relative(projectRoot, src); if (!fs.existsSync(src)) { console.warn(` ⚠ Source not found, skipping: ${relSrc}`); errors++; continue; } process.stdout.write(` 📄 ${relSrc} ... `); try { const result = await generateForFile(src, { force, backend }); if (result.status === "skipped") { console.log("⏭ skipped"); skipped++; } else { console.log(`✅ ${result.count} annotations`); generated++; } } catch (err) { console.log(`❌ ${err.message}`); errors++; } } console.log(`\n──────────────────────────────────────`); console.log(`Generated: ${generated} Skipped: ${skipped} Errors: ${errors}`); if (errors > 0) process.exit(1); ``` ### Options reference | Flag | Default | Description | | -------------- | ------- | ---------------------------------------------- | | `--dir ` | `blog/` | Directory to scan (relative to project root) | | `--force` | off | Regenerate even if `.eli5.json` already exists | | `--dry-run` | off | Show what would happen, no API calls | ## How the bulk script works Before diving into the migration workflow, it helps to understand exactly what the script does — and does not do. ### How it finds files to annotate The script reads every `.md` and `.mdx` file under `--dir` (default: `blog/`), and searches each one for occurrences of `` using a regular expression. For each match it resolves the source path to an absolute path, deduplicates (a file referenced in multiple posts is only processed once), then processes the list. It does **not** annotate inline fenced code blocks — only `` calls that reference an external file via `source="./files/..."`. ### The skip rule — idempotent by design For every source file, the script first checks whether `.eli5.json` already exists: * **File exists → `⏭ skipped`** — zero API call, zero cost, instant. * **File missing → `✅ N annotations`** — one API call to Claude, file written. This means: * You can run `yarn eli5:bulk` as many times as you want. Only files that are missing their `.eli5.json` will trigger an API call. * The **first run** is the only one that costs money (for existing articles). All subsequent runs are free. * If you add a new article with a `` next month, running `yarn eli5:bulk` again will annotate only that new file. * If the script is interrupted (network error, Ctrl+C), simply re-run it — already generated files are skipped, and only the remaining ones are processed. Use `--force` only when you have intentionally edited a source file and want to regenerate its annotations from scratch. ## Annotating your existing articles If you have a blog with many articles — say 240 — you do not want to run the generator file by file. Here is the recommended migration workflow. ### Phase 1 — Audit with dry-run Before touching the API, get a clear picture of what you have. Run the bulk script with `--dry-run` on your entire blog: ```bash $ node scripts/bulk-eli5.mjs --dry-run ``` This is instant — no API call is made. The output shows every source file referenced by a `` in any post, and tells you whether its `.eli5.json` already exists or still needs to be generated: ```plaintext 🔍 Scanning blog/ for Snippet usages... Found 47 unique source file(s) across 240 posts. [SKIP] blog/2025/03/12-wsl2-tricks/files/wsl.conf ← blog/2025/03/12-wsl2-tricks/index.md [GENERATE] blog/2025/04/05-docker-compose/files/compose.yaml ← blog/2025/04/05-docker-compose/index.md [GENERATE] blog/2025/04/05-docker-compose/files/Dockerfile ← blog/2025/04/05-docker-compose/index.md [GENERATE] blog/2025/06/20-zsh-config/files/.zshrc ← blog/2025/06/20-zsh-config/index.md ... ────────────────────────────────────── Generated: 0 Skipped: 3 Errors: 0 ``` Two things to notice here: 1. **Only 47 source files for 240 posts.** Most posts do not use `` at all — they use inline fenced code blocks. The bulk script ignores those automatically. You are not annotating 240 files; you are annotating only the posts that already use the Snippet component with an external source file. 2. **The `SKIP` / `GENERATE` labels** tell you at a glance which files already have an annotation and which still need one. This makes the dry-run useful after partial runs too. Save the dry-run output if you want a permanent audit record: ```bash $ node scripts/bulk-eli5.mjs --dry-run > eli5-audit.txt ``` ### Phase 2 — Estimate the cost `claude-haiku-4-5-20251001` is priced at a fraction of a cent per call. A typical source file (20–60 lines) costs roughly **$0.0002 – $0.001**. Multiply by the number of `[GENERATE]` entries in your dry-run output. For 47 source files, you are looking at under **$0.05** total. Even if every one of your 240 posts had a source file, the total cost would be under **$0.25**. ### Phase 3 — Generate by scope You can process the entire blog in one command, or scope it to a specific year or month. All paths are relative to the project root. **Process everything at once:** ```bash $ node scripts/bulk-eli5.mjs ``` The script runs sequentially (one API call at a time), so there is no risk of hitting rate limits. On a typical connection, 47 files take about two minutes. **Process a single year:** ```bash node scripts/bulk-eli5.mjs --dir blog/2025 node scripts/bulk-eli5.mjs --dir blog/2026 ``` **Process a single month:** ```bash node scripts/bulk-eli5.mjs --dir blog/2026/06 ``` **Process a single post folder:** ```bash node scripts/bulk-eli5.mjs --dir blog/2025/04/05-docker-compose ``` This last form is equivalent to running `generate-eli5.mjs` individually for each source file in that post — but the bulk script handles deduplication automatically (if the same source file is referenced twice in the same post, it is only generated once). ### Phase 4 — What the output looks like while running ```plaintext 🔍 Scanning blog/2025 for Snippet usages... Found 31 unique source file(s) across 140 posts. 📄 blog/2025/04/05-docker-compose/files/compose.yaml ... ✅ 6 annotations 📄 blog/2025/04/05-docker-compose/files/Dockerfile ... ✅ 5 annotations 📄 blog/2025/04/12-zsh-setup/files/.zshrc ... ✅ 9 annotations 📄 blog/2025/05/01-devcontainer/files/devcontainer.json ... ✅ 7 annotations 📄 blog/2025/06/20-fzf/files/fzf-preview.sh ... ⏭ skipped ... ────────────────────────────────────── Generated: 30 Skipped: 1 Errors: 0 ``` Each line tells you how many lines Claude decided to annotate. A file with `0 annotations` would mean Claude found nothing non-trivial — this can happen for very short or very simple files. A file with `⏭ skipped` already had a `.eli5.json` from a previous run. **The script is safe to interrupt and re-run.** If it stops halfway (network error, API error, Ctrl+C), just run it again. Files that were already generated will show `⏭ skipped`; only the remaining ones will be processed. ### Phase 5 — Handle errors If a file fails (Claude returns malformed JSON, network timeout), it shows: ```plaintext 📄 blog/2025/04/05-docker-compose/files/Makefile ... ❌ Invalid JSON from Claude ``` The script continues with the next file and exits with code `1` at the end (so a CI pipeline would catch it). To retry only the failed file: ```bash node scripts/generate-eli5.mjs blog/2025/04/05-docker-compose/files/Makefile ``` If the same file keeps failing, add `--force` to regenerate with a fresh API call. ### Phase 6 — Verify a sample Before committing, open a post in `yarn start` and check that the `?` badges appear on the right lines. Inspect one or two `.eli5.json` files to confirm the quality of explanations. > **No build required — dev mode works out of the box:** > > The `?` badges are injected by the remark plugin at MDX compile time, not at runtime. This means `yarn start` (port 3000) and `yarn build` produce identical results for ELI5 annotations. You never need to run a full build just to preview tooltips. > > If you generate a new `.eli5.json` while the dev server is already running, save the corresponding `.md` file to trigger MDX recompilation — the badges will appear on the next hot reload. If an explanation is wrong or too verbose, edit the JSON file directly — it is just plain text, no regeneration needed. ### Phase 7 — Commit Commit the `.eli5.json` files alongside the source files they annotate. This is intentional: CI/CD does not need `ANTHROPIC_API_KEY` because the annotations are pre-generated and stored in git. ```bash Stage all newly generated annotation files git add blog/**/*.eli5.json git commit -m "feat: add ELI5 annotations for existing Snippet blocks" ``` ### Summary — migration in five commands ```bash 1. Preview scope (no API calls) node scripts/bulk-eli5.mjs --dry-run 2. Generate by year (repeat for each year) node scripts/bulk-eli5.mjs --dir blog/2025 node scripts/bulk-eli5.mjs --dir blog/2026 3. Verify in the browser yarn start 4. Commit git add blog/**/*.eli5.json && git commit -m "feat: ELI5 annotations" ``` ## Step 3 — Extend the remark plugin Your `remark-snippet-loader` already reads the source file and injects `code` and `lang` props into the `` node at build time. If you have never written such a plugin, my [search\&replace plugin for Docusaurus](/blog/docusaurus-plugin-replace) article explains the remark plugin anatomy from scratch. Add eleven lines to also inject the ELI5 annotations when a `.eli5.json` exists alongside the source file. In `plugins/remark-snippet-loader/index.cjs`, after the block that pushes the `lang` attribute: **./files/extend\_remark.js** ```javascript // Auto-inject ELI5 explanations if a .eli5.json file exists const eli5Path = absolutePath + ".eli5.json"; if (fs.existsSync(eli5Path)) { try { const eli5Raw = fs.readFileSync(eli5Path, "utf-8"); const eli5Data = JSON.parse(eli5Raw); if (eli5Data.explanations && typeof eli5Data.explanations === "object") { node.attributes.push({ type: "mdxJsxAttribute", name: "eli5json", value: JSON.stringify(eli5Data.explanations), }); } } catch (e) { console.warn(`Snippet plugin: could not parse ELI5 file ${eli5Path}:`, e.message); } } ``` The annotations are serialized as a JSON string (`eli5json` prop) so they fit into the MDX attribute model without needing to build an estree expression node. The `Snippet` component parses this string back into an object with `JSON.parse`. This approach means **the author does not need to change a single line of MDX**. Add the `.eli5.json` file, rebuild — the tooltips appear automatically. ## Step 4 — Update the Snippet component The [Snippet component](/blog/docusaurus-snippets) needs two changes: a new `Eli5CodeBlock` subcomponent that renders code line by line with badges, and a small update to the main `Snippet` function to parse `eli5json` and route to the new renderer. **src/components/Snippet/index.tsx** ```typescript import React, { useState, useRef, useEffect, useCallback, useMemo, useId, type CSSProperties, type JSX, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import Prism from "prismjs"; import { useTokenize } from "prism-react-renderer"; // Eli5CodeBlock (below) needs its own Prism grammars registered on this exact // `prismjs` singleton — everything beyond markup/html/xml/css/clike/javascript // (Prism's default bundle) only exists here as a side effect of requiring its // component file. `prism-react-renderer` (peer-depends on "prismjs": "*", so // it shares this same module instance) registers `additionalLanguages` the // same way whenever a native `` renders — meaning `Prism.languages // .bash` etc. was previously only defined here if some *other* CodeBlock had // already rendered first in this process/session. Registering every grammar // `Eli5CodeBlock` can receive (see remark-snippet-loader's `extensionToLang` // map) unconditionally, here, makes that no longer order-dependent. // markup/html/xml/css/javascript need no entry — already in Prism's default // bundle (verified empirically). import "prismjs/components/prism-jsx"; import "prismjs/components/prism-typescript"; import "prismjs/components/prism-python"; import "prismjs/components/prism-markup-templating"; // prism-php's own dependency import "prismjs/components/prism-php"; import "prismjs/components/prism-bash"; import "prismjs/components/prism-json"; import "prismjs/components/prism-yaml"; import "prismjs/components/prism-markdown"; import "prismjs/components/prism-docker"; import "prismjs/components/prism-ini"; import "prismjs/components/prism-sql"; import CodeBlock from "@theme/CodeBlock"; import IconCopy from "@theme/Icon/Copy"; import IconSuccess from "@theme/Icon/Success"; import LogoIcon from "@site/src/components/Blog/LogoIcon"; import clsx from "clsx"; import { useVarResolver } from "@site/src/components/Vars/store"; import { substitutePlainText, substituteChildren, } from "@site/src/components/Vars/substitute"; import VarToken from "@site/src/components/Vars/VarToken"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; // Extract language from inside children const getLanguageFromChildren = (children: ReactNode): string | null => { const findLang = (node: ReactNode): string | null => { if (React.isValidElement(node)) { const props = node.props as { className?: unknown; children?: ReactNode; }; if (typeof props.className === "string") { const match = props.className.match(/language-(\w+)/); if (match) return match[1].toLowerCase(); } if (props.children) { const nested = props.children; if (Array.isArray(nested)) { for (const child of nested) { const lang = findLang(child); if (lang) return lang; } } else { return findLang(nested); } } } return null; }; return findLang(children); }; // Maps code language to styling variant const mapLangToVariant: Record = { apacheconf: "apacheconf", asm: "asm", bas: "vb", bash: "bash", bashrc: "bash", bat: "batch", batch: "batch", cjs: "js", cmd: "batch", css: "css", csv: "csv", diff: "diff", docker: "docker", gherkin: "gherkin", html: "html", ignore: "ignore", ini: "ini", java: "java", javascript: "js", js: "js", jsx: "js", json: "json", log: "log", makefile: "makefile", markdown: "md", markdownlint_ignore: "json", md: "md", mdx: "md", mjs: "js", none: "none", pascal: "pascal", php: "php", plaintext: "txt", powershell: "powershell", ps1: "powershell", py: "python", python: "python", qmd: "md", sh: "bash", sql: "sql", svg: "svg", toml: "toml", ts: "ts", tsx: "ts", txt: "txt", typescript: "ts", vb: "vb", vba: "vb", vbnet: "vbnet", vbs: "vb", xml: "xml", yaml: "yaml", yml: "yaml", zsh: "bash", }; // Map variant keys to their icon components and CSS classes // For retrieving the value for the iconify key, just go to "https://icon-sets.iconify.design/?query=python" // and, in the top right search area, type the name of the language. // When found, click on the desired icon and retrieve the "icon name" as displayed in the bottom part. interface VariantIcon { iconClassName: string; iconify: string; ariaLabel: string; } const variantIcons: Record = { apacheconf: { iconClassName: styles.apacheconf_icon, iconify: "logos:apache", ariaLabel: "ApacheConf Logo", }, asm: { iconClassName: styles.asm_icon, iconify: "vscode-icons:file-type-assembly", ariaLabel: "Asm Logo", }, bash: { iconClassName: styles.bash_icon, iconify: "devicon:bash", ariaLabel: "Bash Logo", }, batch: { iconClassName: styles.batch_icon, iconify: "file-icons:ms-dos", ariaLabel: "Batch Logo", }, css: { iconClassName: styles.css_icon, iconify: "ph:file-css", ariaLabel: "CSS Logo", }, csv: { iconClassName: styles.csv_icon, iconify: "ph:file-csv", ariaLabel: "CSV Logo", }, diff: { iconClassName: styles.diff_icon, iconify: "ph:git-diff", ariaLabel: "Diff Logo", }, docker: { iconClassName: styles.docker_icon, iconify: "uil:docker", ariaLabel: "Docker Logo", }, docusaurus: { iconClassName: styles.docker_docusaurus, iconify: "logos:docusaurus", ariaLabel: "Docusaurus Logo", }, gherkin: { iconClassName: styles.gherkin_icon, iconify: "skill-icons:gherkin-light", ariaLabel: "Gherkin Logo", }, html: { iconClassName: styles.html_icon, iconify: "ph:file-html", ariaLabel: "HTML Logo", }, ignore: { iconClassName: styles.ignore_icon, iconify: "codicon:sync-ignored", ariaLabel: "Ignore Logo", }, ini: { iconClassName: styles.ini_icon, iconify: "ph:file-ini", ariaLabel: "INI Logo", }, java: { iconClassName: styles.java_icon, iconify: "vscode-icons:file-type-java", ariaLabel: "Java Logo", }, js: { iconClassName: styles.js_icon, iconify: "logos:javascript", ariaLabel: "JS Logo", }, json: { iconClassName: styles.json_icon, iconify: "ix:json-document", ariaLabel: "JSON Logo", }, log: { iconClassName: styles.log_icon, iconify: "ix:log", ariaLabel: "Log Logo", }, makefile: { iconClassName: styles.makefile_icon, iconify: "vscode-icons:file-type-makefile", ariaLabel: "GNU Makefile Logo", }, md: { iconClassName: styles.md_icon, iconify: "ph:markdown-logo", ariaLabel: "Markdown Logo", }, none: { iconClassName: styles.none_icon, iconify: "ph:empty", ariaLabel: "None Logo", }, pascal: { iconClassName: styles.pascal_icon, iconify: "file-icons:pascal", ariaLabel: "Pascal Logo", }, php: { iconClassName: styles.php_icon, iconify: "bi:filetype-php", ariaLabel: "PHP Logo", }, powershell: { iconClassName: styles.powershell_icon, iconify: "file-icons:powershell", ariaLabel: "Powershell Logo", }, python: { iconClassName: styles.python_icon, iconify: "devicon:python", ariaLabel: "Python Logo", }, sql: { iconClassName: styles.sql_icon, iconify: "ph:file-sql", ariaLabel: "SQL Logo", }, svg: { iconClassName: styles.svg_icon, iconify: "ph:file-svg", ariaLabel: "SVG Logo", }, toml: { iconClassName: styles.toml_icon, iconify: "tabler:toml", ariaLabel: "Toml Logo", }, ts: { iconClassName: styles.ts_icon, iconify: "logos:typescript-icon", ariaLabel: "TypeScript Logo", }, txt: { iconClassName: styles.txt, iconify: "ph:file-txt", ariaLabel: "Txt Logo", }, vb: { iconClassName: styles.vb_icon, iconify: "fluent:document-vb-16-regular", ariaLabel: "VB Logo", }, vbnet: { iconClassName: styles.vbnet_icon, iconify: "fluent:document-vb-16-regular", ariaLabel: "VbNet Logo", }, xml: { iconClassName: styles.xml_icon, iconify: "hugeicons:xml-01", ariaLabel: "XML Logo", }, yaml: { iconClassName: styles.yaml_icon, iconify: "devicon-plain:yaml", ariaLabel: "YAML Logo", }, }; // ELI5 line-by-line renderer — used when eli5json prop is present. // Calls Prism.highlight() directly to get syntax-colored HTML per line, // then overlays interactive ? badges for annotated lines. // The tooltip is rendered via a React Portal into document.body so it // escapes every overflow:hidden / stacking-context ancestor. // C0 control characters other than tab/newline/carriage return, plus DEL. A few example files // carry them for real — `make.bat` colours its output with ESC sequences — and a raw control // character in the page is invalid HTML (the SSG minifier flags it on every build, in every // locale). They are shown as their Unicode "control picture" (ESC -> ␛) instead: visible, honest // about what the file contains, and valid markup. const isControlChar = (code: number): boolean => (code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f; function hasControlChars(text: string): boolean { for (let i = 0; i < text.length; i += 1) { if (isControlChar(text.charCodeAt(i))) return true; } return false; } function showControlChars(text: string): string { if (!hasControlChars(text)) return text; let out = ""; for (let i = 0; i < text.length; i += 1) { const code = text.charCodeAt(i); // U+2400..U+241F picture each C0 control; U+2421 pictures DEL. out += isControlChar(code) ? String.fromCharCode(code === 0x7f ? 0x2421 : 0x2400 + code) : text[i]; } return out; } interface Eli5CodeBlockProps { code: string; lang: string; eli5: Record; } // Eli5CodeBlock renders its own
, bypassing Docusaurus's native
//  (see below) and losing its built-in copy button in the
// process — reimplemented locally rather than pulling in
// `@docusaurus/theme-common/internal`'s CodeBlockContext, which TODO 0112
// already names as a suspect in an open React hydration bug.
function useCopyToClipboard() {
  const [copied, setCopied] = useState(false);
  const timeoutRef = useRef(undefined);

  const copy = useCallback((text: string) => {
    navigator.clipboard.writeText(text).then(() => {
      window.clearTimeout(timeoutRef.current);
      setCopied(true);
      timeoutRef.current = window.setTimeout(() => setCopied(false), 1000);
    });
  }, []);

  useEffect(() => () => window.clearTimeout(timeoutRef.current), []);

  return { copied, copy };
}

function Eli5CodeBlock({ code, lang, eli5 }: Eli5CodeBlockProps): JSX.Element {
  const [activeLine, setActiveLine] = useState(null);
  const [tooltipStyle, setTooltipStyle] = useState(null);
  const { copied, copy } = useCopyToClipboard();
  // false until mounted client-side — gates the tooltip's document.body Portal,
  // which doesn't exist during SSR.
  const [mounted, setMounted] = useState(false);
  const badgeRefs = useRef>({});

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount flag, see comment on `mounted` above
    setMounted(true);
  }, []);

  // `Prism.highlight()` + `.split("\n")` (the previous approach) corrupts any
  // token that spans multiple lines — a `/** … */` JSDoc comment most
  // commonly — because the highlighted HTML gets cut across an unclosed
  // ``. Browsers silently repair that malformed HTML while parsing the
  // page, but repair it *differently* than what React's virtual tree expects
  // once it hydrates, which is a genuine structural mismatch (React error
  // #418) on any snippet whose language has multi-line comments/strings —
  // confirmed via a server-vs-hydrated-DOM diff on `docusaurus-cards.mdx`
  // (see TODO 0112). `useTokenize` avoids the string-splitting step entirely:
  // it walks Prism's real token tree and only ever hands back whole tokens,
  // already split at line boundaries.
  const grammar = useMemo(
    () => Prism.languages[lang] || Prism.languages.plaintext || Prism.languages.clike,
    [lang],
  );
  const tokenizedLines = useTokenize({ prism: Prism, code, grammar, language: lang });
  // A trailing `\n` in `code` makes normalizeTokens() append one synthetic
  // last "line" holding a single `{ content: "\n", empty: true }` token —
  // drop it so line numbers keep matching `eli5`'s 1-based keys, exactly like
  // the previous `rawLines.pop()` did for a trailing blank line.
  const lines = useMemo(() => {
    const last = tokenizedLines[tokenizedLines.length - 1];
    if (last?.length === 1 && last[0].empty) {
      return tokenizedLines.slice(0, -1);
    }
    return tokenizedLines;
  }, [tokenizedLines]);

  // Compute position: tooltip appears above the badge, right-aligned.
  const positionTooltip = useCallback((lineNum: string) => {
    const el = badgeRefs.current[lineNum];
    if (!el) return;
    const rect = el.getBoundingClientRect();
    setTooltipStyle({
      top: rect.top, // translateY(-100%-gap) shifts it above
      right: window.innerWidth - rect.right, // right-align with the badge
    });
  }, []);

  const handleBadgeClick = useCallback(
    (lineNum: string) => setActiveLine((prev) => (prev === lineNum ? null : lineNum)),
    [],
  );

  // Keep the tooltip glued to its badge: position is computed once on
  // hover/focus, but `position: fixed` doesn't move with scrolling — without
  // this, scrolling the page (or the horizontally-scrollable code block)
  // while a tooltip is open leaves it stranded at its old screen position.
  useEffect(() => {
    if (!activeLine) return undefined;
    const reposition = () => positionTooltip(activeLine);
    window.addEventListener("scroll", reposition, { capture: true, passive: true });
    window.addEventListener("resize", reposition, { passive: true });
    return () => {
      window.removeEventListener("scroll", reposition, { capture: true });
      window.removeEventListener("resize", reposition);
    };
  }, [activeLine, positionTooltip]);

  const activeExplanation = activeLine ? eli5[activeLine] : null;

  return (
    <>
      
        
        
          {lines.map((lineTokens, i) => {
            const lineNum = String(i + 1);
            const explanation = eli5[lineNum];
            const isActive = activeLine === lineNum;

            return (
              
{lineTokens.map((token, tokenIdx) => ( {/* Display only: the copy button still copies `code`, real bytes and all. */} {showControlChars(token.content)} ))} {explanation ? ( ) : ( )}
); })}
{mounted && activeExplanation && tooltipStyle && createPortal( {activeExplanation} , document.body, )} ); } // ELI5 verbose summary — a Show/Hide disclosure rendered below the code block, // giving a narrative explanation of the whole snippet (as opposed to // Eli5CodeBlock's per-line tooltips). Kept as its own component, deliberately // outside Eli5CodeBlock's render tree — see TODO 0112 (open React hydration // bug on Eli5CodeBlock/): this feature must not add surface area // there. Also kept as a sibling of the collapsible `snippet_content` div // (not nested inside it): nesting would let this block's own open/close // change the *inner* scrollHeight without the outer collapsible's height // (computed once per its own toggle) ever re-measuring, clipping the summary // whenever a reader opens it after the snippet was already expanded. interface Eli5SummaryBlockProps { summary: string; } function Eli5SummaryBlock({ summary }: Eli5SummaryBlockProps): JSX.Element { const [open, setOpen] = useState(false); const contentRef = useRef(null); const [height, setHeight] = useState("0px"); const contentId = `eli5-summary-${useId()}`; useEffect(() => { if (contentRef.current) { setHeight(open ? `${contentRef.current.scrollHeight}px` : "0px"); } }, [open, summary]); return (

{summary}

); } interface Props { filename?: string; title?: ReactNode; code?: string; children?: ReactNode; /** * Force the open/closed state. Leave undefined and the component decides: * open, unless the snippet is longer than COLLAPSE_OVER_LINES. * * Pass `false` explicitly on file-index sections (ProjectSetup articles, * "All Files at a Glance"): there the page is a LIST of files the reader * scans and opens one of, not a narrative the code belongs to. */ defaultOpen?: boolean; variant?: string; lang?: string; eli5json?: string; eli5summary?: string; } /** * Above this many lines a snippet opens PARTIALLY: the first lines are shown * under a fade, with a "show the remaining N lines" control. Below it, the * snippet simply opens. * * 30 is roughly one screenful of code at 0.85rem/1.6 on a laptop — the point * where "I can take this in at a glance" stops being true. */ const COLLAPSE_OVER_LINES = 30; /** Height of the peek window, in lines. Deliberately less than * COLLAPSE_OVER_LINES so a truncated block always LOOKS truncated. */ const PEEK_LINES = 18; export default function Snippet({ filename, title, code, children, defaultOpen, variant, lang: pluginLang, eli5json, eli5summary, }: Props): JSX.Element { const contentRef = useRef(null); // Parse eli5json string (injected by remark-snippet-loader) into an object const eli5 = useMemo((): Record | null => { if (!eli5json || typeof eli5json !== "string") return null; try { return JSON.parse(eli5json); } catch { return null; } }, [eli5json]); const eli5Summary = typeof eli5summary === "string" && eli5summary.trim() ? eli5summary.trim() : null; const lineCount = useMemo( () => (typeof code === "string" ? code.replace(/\n$/, "").split("\n").length : 0), [code], ); const isLong = lineCount > COLLAPSE_OVER_LINES; /* * The default is OPEN. A reader who clicked "Install Docker under WSL2" came * for the compose file; making them click again to see it is asking them to * work for the thing they searched for. * * Three concrete costs of the old closed-by-default, beyond the extra click: * - find-in-page broke. The collapsed content sits in a max-height: 0 / * overflow: hidden box, which Ctrl+F cannot reach: a reader searching the * page for a variable name got "no results" while the word was right * there. * - printing produced empty boxes. * - the ELI5 "Explain this snippet" control is gated on `open`, so the * line-by-line explanations nobody had expanded were never discovered. * * Long files do not get the old all-or-nothing treatment either: they open * truncated (see PEEK_LINES), so the reader sees WHAT it is immediately and * the page is not eaten by 300 lines. */ const [open, setOpen] = useState(defaultOpen ?? true); const [expandedLong, setExpandedLong] = useState(false); const handleToggle = useCallback(() => setOpen((prev) => !prev), []); const handleExpandLong = useCallback(() => setExpandedLong(true), []); const contentId = `snippet-content-${useId()}`; const lang = useMemo(() => { // 1. Check for the `lang` prop provided by your plugin if (pluginLang) return pluginLang; // 2. Fallback to the existing logic for `children` const languageFromChildren = getLanguageFromChildren(children); if (languageFromChildren) return languageFromChildren; // 3. Fallback to the filename extension if no other language is found if (typeof filename === "string") { const ext = filename.split(".").pop(); return mapLangToVariant[ext ?? ""] || "plaintext"; } return "plaintext"; }, [pluginLang, children, filename]); // Resolve `default` markers (TODO 0088) before rendering. `code` // (from `source=`/`code=`, including files loaded at build time) is plain // text feeding Prism, so substitution there is a string swap — no visible // "this is yours" underline, which the `children` path below gets for // free; an acceptable v1 trade-off, see Vars/readme.md. const resolve = useVarResolver(); const resolvedCode = useMemo( () => (code ? substitutePlainText(code, resolve) : code), [code, resolve], ); const resolvedChildren = useMemo( () => substituteChildren(children, resolve, (name, value, key) => ( {value} )), [children, resolve], ); // Use line-by-line ELI5 renderer when annotations are available and code is a string. // Otherwise fall back to Docusaurus CodeBlock (or raw children). // // Code holding control characters also takes the ELI5 renderer, with no annotations: it is the // only one that can show them escaped while its copy button keeps the original bytes. The // native would either print them raw or copy the escaped version. const codeBlock = useMemo(() => { const hasEli5 = Boolean(eli5 && Object.keys(eli5).length > 0); if (resolvedCode && (hasEli5 || hasControlChars(resolvedCode))) { return ; } if (resolvedCode) { return {resolvedCode}; } return resolvedChildren; }, [resolvedCode, eli5, lang, resolvedChildren]); const baseName = useMemo( () => typeof filename === "string" ? (filename.split("/").pop() ?? "").toLowerCase() : null, [filename], ); const isDockerFile = useMemo(() => { if (!baseName) return false; return ( baseName === "dockerfile" || baseName === "docker" || baseName.endsWith(".docker") || baseName.endsWith(".dockerfile") || baseName.endsWith(".dockerignore") || baseName.endsWith("compose.yaml") || baseName.endsWith("compose.yml") || baseName.endsWith("docker-compose.yaml") ); }, [baseName]); const isDocusaurus = useMemo(() => baseName === "docusaurus.config.js", [baseName]); const variantKey = useMemo( () => variant || (isDockerFile ? "docker" : isDocusaurus ? "docusaurus" : mapLangToVariant[lang]) || "none", [variant, isDockerFile, isDocusaurus, lang], ); const variantClass = styles[`variant_${variantKey}`] || ""; const IconInfo = variantIcons[variantKey] || variantIcons.none; const { iconify, ariaLabel } = IconInfo; const displayTitle = title || filename || (lang ? lang.toUpperCase() : translate({ id: "snippet.defaultTitle", message: "Snippet" })); // Peek mode: long, open, and the reader has not asked for the rest yet. const peeking = open && isLong && !expandedLong; return (
{/* Conditional render, not a max-height animation on a permanently mounted div. With the old approach the collapsed code stayed in the DOM inside an overflow: hidden box — invisible to Ctrl+F but still found by a screen reader, which is the worst of both. Unmounting is honest: closed means closed. */} {open && (
{codeBlock}
{peeking && ( )}
)} {open && eli5Summary && }
); } ``` ### How the renderer works When `eli5json` is present and `code` is a string, the component calls `Prism.highlight()` directly instead of delegating to Docusaurus's ``. Prism is already bundled by Docusaurus — importing it is safe and has no bundle-size cost. The highlighted HTML is split by newline. Each line becomes a `
` containing: * A `` with the syntax-highlighted code (via `dangerouslySetInnerHTML` — safe because Prism only wraps tokens in `` elements) * Either a `?` button (if the line has an explanation) or an invisible placeholder of the same width (to keep all lines aligned) The tooltip appears on hover via a `onMouseEnter`/`onMouseLeave` pair on the badge wrapper, and toggles on click for keyboard/mobile access. Focus/blur handlers make it keyboard-accessible without extra ARIA trickery. When `eli5json` is absent (the normal case), the component behaves exactly as before — `` is used, nothing changes. ## Step 5 — Add the CSS Add these classes to `src/components/Snippet/styles.module.css`: **src/components/Snippet/styles.module.css** ```css .eli5_pre { margin: 0; padding: 0.75rem 0; overflow-x: auto; background: var(--prism-background-color, #282c34); border-radius: 0 0 4px 4px; font-family: var(--ifm-font-family-monospace); font-size: var(--ifm-code-font-size, 0.875rem); line-height: 1.5; } .eli5_line { display: flex; align-items: flex-start; min-height: 1.5em; padding: 0 1rem; } .eli5_line:hover { background: rgba(255, 255, 255, 0.04); } .eli5_code { flex: 1; white-space: pre; overflow: hidden; } .eli5_badge_placeholder { display: inline-block; width: 1.2rem; flex-shrink: 0; margin-left: 0.5rem; } .eli5_badge_wrapper { position: relative; display: inline-flex; align-items: center; flex-shrink: 0; margin-left: 0.5rem; } .eli5_badge { display: inline-flex; align-items: center; justify-content: center; width: 1.2rem; height: 1.2rem; border-radius: 50%; border: 1px solid rgba(150, 150, 200, 0.35); background: rgba(100, 100, 180, 0.12); color: rgba(180, 180, 230, 0.6); font-size: 0.6rem; font-weight: 700; line-height: 1; padding: 0; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; flex-shrink: 0; } .eli5_badge:hover, .eli5_badge:focus-visible, .eli5_badge_active { background: rgba(80, 130, 230, 0.35); color: #e8eaf6; border-color: rgba(120, 170, 255, 0.65); outline: none; } .eli5_tooltip { position: absolute; right: 0; top: calc(100% + 5px); z-index: 200; width: 270px; max-width: 90vw; padding: 0.5rem 0.75rem; background: var(--ifm-background-surface-color, #fff); border: 1px solid var(--ifm-color-emphasis-300); border-radius: 6px; box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18); font-family: var(--ifm-font-family-base); font-size: 0.8rem; line-height: 1.55; color: var(--ifm-font-color-base); white-space: normal; pointer-events: none; } .eli5_tooltip::before { content: ""; position: absolute; top: -5px; right: 4px; width: 8px; height: 8px; background: var(--ifm-background-surface-color, #fff); border-left: 1px solid var(--ifm-color-emphasis-300); border-top: 1px solid var(--ifm-color-emphasis-300); transform: rotate(45deg); } [data-theme="dark"] .eli5_tooltip { background: #1e2030; border-color: rgba(255, 255, 255, 0.1); box-shadow: 0 4px 22px rgba(0, 0, 0, 0.5); } [data-theme="dark"] .eli5_tooltip::before { background: #1e2030; border-color: rgba(255, 255, 255, 0.1); } ``` The `eli5_badge_placeholder` is worth explaining: every line in the code block must have a right-side element of the same width, whether it has a badge or not. Without the placeholder, lines without a `?` would be narrower and the code would appear ragged. ## Author workflow (end to end) For each blog post that contains `` blocks, the workflow after publishing once is: ```bash 1. Write your post. Place source files in ./files/ as usual Example: blog/2026-07-01-my-docker-post/files/Dockerfile 2. Generate ELI5 annotations $ yarn eli5 blog/2026-07-01-my-docker-post/files/Dockerfile 3. Preview locally — the ? badges appear automatically $ yarn start 4. Commit the source file and the annotation file $ git add blog/2026-07-01-my-docker-post/files/Dockerfile $ git add blog/2026-07-01-my-docker-post/files/Dockerfile.eli5.json 5. Build and deploy as usual — no ANTHROPIC_API_KEY needed in CI $ yarn build ``` If the source file changes after publication, regenerate with `--force`: ```bash $ yarn eli5 blog/2026-07-01-my-docker-post/files/Dockerfile --force ``` ## Environment setup ### Step 1 — Create an Anthropic account and get an API key > **API access is separate from Claude Pro:** > > If you already have a **Claude Pro** subscription (claude.ai), that covers the chat interface only — it does not include API access. The generation scripts use the **Anthropic API**, which is a separate, pay-as-you-go product billed by token usage. You need a distinct account on `console.anthropic.com` with a credit balance. The good news: annotating 240 articles with `claude-haiku-4-5` costs a few cents in total (Haiku is the least expensive Claude model, at $1 per million input tokens, and each source file is only a few hundred tokens). Go to **** and sign up (or log in if you already have an account). Once logged in: 1. Click **Settings** in the left sidebar (or navigate directly to `https://console.anthropic.com/settings/keys`). 2. Select **API Keys** from the settings menu. 3. Click **Create Key**. 4. Give your key a name (e.g. `docusaurus-eli5`) so you can identify it later. 5. **Copy the key immediately** — it is shown only once and cannot be retrieved after you close the dialog. It looks like `sk-ant-api03-...`. Store the key somewhere safe (a password manager works well). If you lose it, you will need to create a new one. > **Never expose your API key:** > > Do not paste your key into source code, commit it to git, or share it publicly. A leaked key can be used to make API calls billed to your account. You will also need a **billing credit balance** to make API calls. In the Anthropic console, go to **Settings → Billing** and add a small credit (a few US dollars is more than enough to annotate hundreds of blog posts with Haiku — the cost is fractions of a cent per file). ### Step 2 — Add the key to your project Create a `.env` file at the root of your Docusaurus project (next to `package.json`): ```dotenv # .env — do not commit this file ANTHROPIC_API_KEY=sk-ant-api03-... ``` Then make sure `.env` is in your `.gitignore`: ```bash $ echo ".env" >> .gitignore ``` The key is read by the generation scripts at authoring time only. It is **never** included in the built site or needed in CI — the `.eli5.json` annotation files are committed to git alongside the source files. ### Step 3 — Install dependencies `dotenv` and `@anthropic-ai/sdk` are already present as dependencies in this project. If you are starting a fresh Docusaurus project, install them as dev dependencies: ```bash $ yarn add --dev @anthropic-ai/sdk dotenv ``` ## Known limitations * **Requires the `source` prop** — the ELI5 renderer activates only when the remark plugin injects `code` as a string (i.e., when you use `source="./files/myfile"`). Snippets that use inline `children` (fenced code blocks inside the MDX) are not annotated. * **Stale annotations** — if you edit a source file significantly after generating `.eli5.json`, the annotations may no longer match the lines. Run `--force` to refresh. There is no automatic staleness detection. * **Long lines** — if a line wraps visually (overflow-x scrolls), the `?` badge stays at the end of the logical line, which may be off-screen. This is an edge case for most file types (Dockerfile, YAML, bash) where lines are short. * **Prism language coverage** — `Prism.highlight()` falls back to plain text for languages not registered in Docusaurus's Prism bundle. The most common file types (Dockerfile, YAML, Bash, JSON, JavaScript) are all in the default bundle. ## What Claude model to use The scripts use `claude-haiku-4-5-20251001`, which is the fastest and least expensive Claude model. For a 20-line Dockerfile, one call costs a fraction of a cent and returns in under two seconds. The quality is sufficient for the ELI5 use case — short, factual explanations of well-known constructs. If you want more nuanced explanations (for example, for complex shell pipelines or advanced YAML anchors), switch to `claude-sonnet-4-6` in the scripts. The prompt and output format remain identical. ## Extending the idea A few directions worth exploring once the base feature is working: * **Difficulty levels** — the prompt could ask Claude to tag each explanation as `beginner` / `intermediate` / `advanced` and the badge could change color accordingly (`?` in green/yellow/red). * **Localization** — pass a `lang` prop to the script and add `Please respond in French` to the system prompt. The `.eli5.json` format already has a `lang` field for this. * **Staleness detection** — add a `sourceHash` field (SHA-256 of the source file at generation time) to `.eli5.json`. The remark plugin can compare it to the current file hash and emit a warning during build when they diverge. * **Automatic regeneration hook** — a `yarn build` pre-hook (`"prebuild": "node scripts/bulk-eli5.mjs"`) would regenerate all missing annotations before each build. Combined with `ANTHROPIC_API_KEY` in CI secrets, this makes annotation generation fully automatic. --- # Adding Reader Reactions to Your Docusaurus Blog > Build a fully working "Was this article helpful?" widget for your Docusaurus blog. Step-by-step guide covering the PHP backend, React component, CSS module, Docusaurus swizzle, and an admin dashboard — everything you need to reproduce it from scratch. --- ![Adding Reader Reactions to Your Docusaurus Blog](/img/v2/docusaurus_like_button.webp) **TL;DR** This guide walks through building a "Was this article helpful?" widget for a Docusaurus blog — from scratch, end to end. You will create a lightweight PHP script that stores votes in a JSON file and sends throttled email notifications, a React component that reads and writes those votes, a CSS module that blends seamlessly with any Docusaurus theme, and a swizzle of `BlogPostItem` to inject the widget **at the bottom** of every post. A bonus admin dashboard page lets you monitor approval rates across all articles at a glance. Reader feedback is one of those things that looks simple from the outside but hides a surprising amount of moving parts once you start building it. I wanted something unobtrusive — no third-party services, no cookies, no sign-up required. Just a small "Was this article helpful?" widget at the bottom of every post, a JSON file on the server, and an email in my inbox when a reader votes. What followed was a self-contained system: a 160-line PHP backend, a React component, a CSS module, and a single swizzle. Everything lives in your own repository and under your own hosting. No external dependencies, no privacy implications, no recurring costs. In this article I'll walk you through every file, every decision, and every line that matters — so you can reproduce it on your own Docusaurus blog and adapt it to your needs. **Quick Jump:** [The Big Picture](#the-big-picture) | [All Files at a Glance](#all-files-at-a-glance) > Screenshot — https\://www\.avonture.be/blog/docusaurus-easter-eggs > > ![Reactions](./images/reactions.webp) ## The Big Picture Before diving in, here is what we are going to build and how the pieces fit together: ```plaintext Browser Your Server │ │ ├─ GET /api/reactions.php?slug=... ─▶ reactions-data.json (read) │◀─ { helpful: 12, not_helpful: 3 } ─┤ │ │ ├─ POST /api/reactions.php ─▶ reactions-data.json (write) │ { slug, vote } │ + email notification │◀─ { helpful: 13, not_helpful: 3 } ─┤ │ │ └─ GET /api/reactions.php?admin=... ─▶ reactions-data.json (full dump) ``` **Five files are involved:** | File | Role | | ------------------------------------------- | ----------------------------------------------------- | | `api/reactions.php` | PHP backend: stores votes, sends emails | | `src/components/Reaction/index.tsx` | React component rendered at the bottom of every post | | `src/components/Reaction/styles.module.css` | CSS module for the widget | | `src/theme/BlogPostItem/index.js` | Swizzled Docusaurus component that injects the widget | | `src/pages/reactions-dashboard.js` | Admin page to visualize all votes | Two JSON files are created at runtime by the backend: * `api/reactions-data.json` — the vote store, one entry per article slug * `api/notifications.json` — throttle timestamps, one entry per article slug Let's build them one by one. *** ## Step 1 — The PHP backend Create the file `api/reactions.php` at the root of your web server (next to, or inside, your Docusaurus output folder). The PHP script does not need to be inside the Docusaurus source tree at all; it just needs to be reachable by the browser. ### 1.1 — Configuration **api/reactions.php** ```php $message]); exit; } // Sends an email notification for a new vote, with a per-article cooldown. function maybeNotify(string $slug, string $vote, array $counts): void { $throttleFile = __DIR__ . '/notifications.json'; $throttle = loadData($throttleFile); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) { return; } $articleUrl = SITE_URL . '/' . $slug; $dashboardUrl = SITE_URL . '/reactions-dashboard'; $total = $counts['helpful'] + $counts['not_helpful']; $ratio = $total > 0 ? round($counts['helpful'] / $total * 100) : 0; $subject = "[Blog] New reaction on: $slug"; $body = implode("\n", [ "A reader just reacted to one of your blog posts.", "", "Article : $articleUrl", "Vote : $vote", "Helpful : {$counts['helpful']}", "Not helpful : {$counts['not_helpful']}", "Approval : {$ratio}%", "", "View full dashboard: $dashboardUrl", ]); $headers = implode("\r\n", [ "From: " . NO_REPLY, "Reply-To: " . NO_REPLY, "Content-Type: text/plain; charset=utf-8", ]); if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($throttleFile, $throttle); } } // ── Parse input ─────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; $slug = ''; $vote = ''; if ($method === 'GET') { // Admin request: return all reaction data if (array_key_exists('admin', $_GET)) { if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) { jsonError(403, 'Forbidden'); } echo json_encode(loadData(__DIR__ . '/reactions-data.json')); exit; } $slug = sanitizeSlug($_GET['slug'] ?? ''); } elseif ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true) ?? []; $slug = sanitizeSlug($body['slug'] ?? ''); $vote = $body['vote'] ?? ''; } else { jsonError(405, 'Method not allowed'); } if ($slug === '') { jsonError(400, 'Missing slug'); } // ── Load data ───────────────────────────────────────────────────────────────── $dataFile = __DIR__ . '/reactions-data.json'; $data = loadData($dataFile); if (!isset($data[$slug])) { $data[$slug] = ['helpful' => 0, 'not_helpful' => 0]; } // ── Record vote ─────────────────────────────────────────────────────────────── if ($method === 'POST') { if ($vote === 'helpful') { $data[$slug]['helpful']++; } elseif ($vote === 'not_helpful') { $data[$slug]['not_helpful']++; } else { jsonError(400, 'Invalid vote value'); } saveData($dataFile, $data); maybeNotify($slug, $vote, $data[$slug]); } echo json_encode($data[$slug]); ``` Four constants, four decisions to make: * **`ADMIN_EMAIL`** — where email notifications go. Use your own address. * **`ADMIN_TOKEN`** — a long random string (32+ characters). This protects the admin endpoint that returns all votes. Generate one with `openssl rand -base64 24` in your terminal and never commit it to a public repository. * **`NOREPLY`** — your noreply email address. * **`NOTIFY_COOLDOWN_SECONDS`** — the minimum number of seconds between two notification emails *for the same article*. The default is 3 600 (one hour). This prevents your inbox from being flooded when an article goes viral. * **`SITE_URL`** — your public domain, used to build clickable links in the notification email and to validate CORS origins. **Think to change this value to yours.** > **Using a .env file:** > > Don't hardcode your email, token, ... in the PHP file. Add the `api/.env` file next to the `reactions.php` file and replace placeholders by your own values. > > **api/.env** > > ``` > ADMIN_EMAIL=your_email@your_domain > ADMIN_TOKEN=change-me-to-a-long-random-string > NO_REPLY=noreply@your_domain > IP_HASH_SALT=change-me-to-another-long-random-string > NONCE_SECRET=change-me-to-yet-another-long-random-string > ``` ### 1.2 — CORS A word about CORS: in this `api/reactions.php` file, you'll find this snippet: ```php title="api/reactions.php" $allowedOrigins = [ SITE_URL, 'https://localhost:3000', ]; $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if (!in_array($origin, $allowedOrigins, true)) { http_response_code(403); exit; } header('Content-Type: application/json; charset=utf-8'); header("Access-Control-Allow-Origin: $origin"); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); header('Vary: Origin'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } ``` The CORS block does two things. First, it rejects any request that does not originate from your site or from `localhost:3000` (your local Docusaurus dev server). Without this check, anyone could POST fake votes from any origin. The check is strict (`in_array` with the third argument `true`): no substring matching, no wildcards. Second, it echoes the validated origin back in the `Access-Control-Allow-Origin` header. This is more correct than the wildcard `*` approach because it works with credentials and explicitly tells browsers which origin is allowed — and the `Vary: Origin` header ensures that caches respect per-origin responses. The `OPTIONS` branch handles the browser preflight that precedes any cross-origin POST with a `Content-Type: application/json` header. > **Why localhost:3000?:** > > During development, `yarn start` serves Docusaurus on `https://localhost:3000`. Without adding it to the allowed origins list, every vote from your dev environment would be silently rejected by the browser before even reaching the server. ### 1.3 — Email notification with per-article throttling A word about email notification: in this `api/reactions.php` file, you'll find this snippet: ```php title="api/reactions.php" function maybeNotify(string $slug, string $vote, array $counts): void { $throttleFile = __DIR__ . '/notifications.json'; $throttle = loadData($throttleFile); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) { return; } $articleUrl = SITE_URL . '/' . $slug; $dashboardUrl = SITE_URL . '/reactions-dashboard'; $total = $counts['helpful'] + $counts['not_helpful']; $ratio = $total > 0 ? round($counts['helpful'] / $total * 100) : 0; $subject = "[Blog] New reaction on: $slug"; $body = implode("\n", [ "A reader just reacted to one of your blog posts.", "", "Article : $articleUrl", "Vote : $vote", "Helpful : {$counts['helpful']}", "Not helpful : {$counts['not_helpful']}", "Approval : {$ratio}%", "", "View full dashboard: $dashboardUrl", ]); $headers = implode("\r\n", [ "From: " . NO_REPLY, "Reply-To: " . NO_REPLY, "Content-Type: text/plain; charset=utf-8", ]); if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($throttleFile, $throttle); } } ``` The throttle check is the key line: `time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS`. If the last email for this article was sent less than `NOTIFY_COOLDOWN_SECONDS` ago, the function returns immediately without sending anything. The throttle timestamp is only saved when `mail()` actually succeeds, so a failed send does not block the next attempt. The notification email includes the vote type, the running totals, and the approval ratio — enough context to decide whether an article needs attention without having to open the dashboard. > **Adjust the cooldown to your traffic:** > > On a low-traffic blog, a one-hour cooldown is reasonable. If you ever end up on a high-traffic aggregator, consider bumping `NOTIFY_COOLDOWN_SECONDS` to `86400` (24 hours) temporarily. The votes are never lost, you just receive fewer emails about them. ### 1.4 — Routing: GET and POST ```php title="api/reactions.php" $method = $_SERVER['REQUEST_METHOD']; $slug = ''; $vote = ''; if ($method === 'GET') { // Admin request: return all reaction data if (array_key_exists('admin', $_GET)) { if (($_GET['admin'] ?? '') !== ADMIN_TOKEN) { jsonError(403, 'Forbidden'); } echo json_encode(loadData(__DIR__ . '/reactions-data.json')); exit; } $slug = sanitizeSlug($_GET['slug'] ?? ''); } elseif ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true) ?? []; $slug = sanitizeSlug($body['slug'] ?? ''); $vote = $body['vote'] ?? ''; } else { jsonError(405, 'Method not allowed'); } if ($slug === '') { jsonError(400, 'Missing slug'); } ``` The GET branch handles two cases. When the `admin` query parameter is present, the script checks it against `ADMIN_TOKEN`. A matching token returns the full contents of `reactions-data.json` — the data consumed by the dashboard. A non-matching token returns a `403 Forbidden`. When `admin` is absent, the script reads the `slug` query parameter. This is the public endpoint the React component calls on page load to get the current vote counts for a specific article. The POST branch reads the request body as JSON (the React component sends `Content-Type: application/json`). It extracts `slug` and `vote`. Both go through validation in the next section. ```php title="api/reactions.php" $dataFile = __DIR__ . '/reactions-data.json'; $data = loadData($dataFile); if (!isset($data[$slug])) { $data[$slug] = ['helpful' => 0, 'not_helpful' => 0]; } if ($method === 'POST') { if ($vote === 'helpful') { $data[$slug]['helpful']++; } elseif ($vote === 'not_helpful') { $data[$slug]['not_helpful']++; } else { jsonError(400, 'Invalid vote value'); } saveData($dataFile, $data); maybeNotify($slug, $vote, $data[$slug]); } echo json_encode($data[$slug]); ``` On first access, the slug entry is created with zeroed counters. The POST branch increments the right counter, saves the file, and triggers the throttled notification. Any `vote` value other than `helpful` or `not_helpful` is rejected immediately. The final line echoes the (updated or unchanged) counts for this slug, whatever the method. *** ## Step 2 — The React component Create the folder `src/components/Reaction/` and the file `index.tsx` inside it. **src/components/Reaction/index.tsx** ```typescript import { useState, useEffect, useCallback, type JSX } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import useBaseUrl from "@docusaurus/useBaseUrl"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata?: { permalink?: string; }; } interface Counts { helpful: number; not_helpful: number; } export default function Reaction({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/reactions.php`; const storageKey = `reaction_${slug}`; const mascotYesUrl = useBaseUrl("/img/reaction-mascot-yes.webp"); const mascotNoUrl = useBaseUrl("/img/reaction-mascot-no.webp"); const [counts, setCounts] = useState(null); // null until the client-side effect runs — avoids SSR/hydration mismatch (#418). // If voted were read in useState(), SSR would produce null (no localStorage) // while the client would produce "helpful", causing a React tree mismatch. const [voted, setVoted] = useState(null); const [submitError, setSubmitError] = useState(false); useEffect(() => { try { const stored = localStorage.getItem(storageKey); // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment on `voted` above if (stored) setVoted(stored); } catch {} }, [storageKey]); useEffect(() => { if (!slug) return; fetch(`${apiUrl}?slug=${encodeURIComponent(slug)}`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data) setCounts(data); }) .catch(() => {}); }, [slug, apiUrl]); const handleVote = useCallback( async (vote: string) => { setSubmitError(false); try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, vote }), }); if (!res.ok) { setSubmitError(true); return; } const data = await res.json(); setCounts(data); setVoted(vote); try { localStorage.setItem(storageKey, vote); } catch {} } catch { setSubmitError(true); } }, [slug, apiUrl, storageKey], ); if (!slug) return null; return (
{!voted ? ( <> Was this article helpful?
{submitError && ( Could not save your vote — please try again. )} ) : (
{voted === "helpful" ? translate({ id: "reaction.thanksHelpful", message: "Glad it helped! 🙌" }) : translate({ id: "reaction.thanksNotHelpful", message: "Thanks for the feedback!", })} {counts && ( 👍 {counts.helpful} 👎 {counts.not_helpful} )}
)}
); } ``` ### 2.1 — Imports and setup ```tsx title="src/components/Reaction/index.tsx" import { useState, useEffect, useCallback, type JSX } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./styles.module.css"; interface Props { metadata?: { permalink?: string; }; } interface Counts { helpful: number; not_helpful: number; } export default function Reaction({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/reactions.php`; const storageKey = `reaction_${slug}`; ``` `metadata` is the object that Docusaurus passes to every blog post component. We use `metadata.permalink` — the URL path of the article — as the slug. The two `replace` calls strip the leading and trailing slashes so, the slug matches what `sanitizeSlug` on the PHP side produces: `blog/my-article`, not `/blog/my-article/`. The `Props` interface only needs `metadata.permalink`; everything else Docusaurus passes is ignored. `Counts` is the `{ helpful, not_helpful }` shape the PHP endpoint returns — reused below for the `counts` state. `siteConfig.url` is your production URL from `docusaurus.config.js`. The component builds the API URL from it dynamically, which means it also works correctly in development (where `url` will point to `https://localhost:3000` if you configure it that way, but in practice the component is only mounted after Docusaurus resolves the site config). `storageKey` gives every article its own entry in `localStorage` (`reaction_blog-my-article`), preventing a vote on one article from affecting another. ### 2.2 — State ```tsx title="src/components/Reaction/index.tsx" const [counts, setCounts] = useState(null); const [voted, setVoted] = useState(null); const [submitError, setSubmitError] = useState(false); useEffect(() => { try { const stored = localStorage.getItem(storageKey); if (stored) setVoted(stored); } catch {} }, [storageKey]); ``` `counts` holds the `{ helpful, not_helpful }` object returned by the API. It starts as `null` (not yet loaded). `submitError` is a flag flipped on when a vote POST fails, so the render can show a retry message. `voted` holds the previous vote for this article. It starts as `null` and is only populated **after mount**, inside a `useEffect` that reads `localStorage`. The `try/catch` is there because `localStorage` can throw in private browsing modes or when storage is full. > **Why not read localStorage in useState?:** > > Docusaurus server-renders every page. If `voted` were seeded from `localStorage` in a `useState` initializer, the server would produce `null` (no `localStorage` there) while the browser would produce the stored `"helpful"` — the two HTML trees disagree, and React throws a hydration mismatch. Deferring the read to `useEffect` keeps the first client render identical to the server's, then updates. Anything outside `useState`, `useEffect`, or an event handler still needs a `typeof window !== "undefined"` guard. ### 2.3 — Loading counts on mount ```tsx title="src/components/Reaction/index.tsx" useEffect(() => { if (!slug) return; fetch(`${apiUrl}?slug=${encodeURIComponent(slug)}`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data) setCounts(data); }) .catch(() => {}); }, [slug, apiUrl]); ``` On mount, the component fetches the current counts for this article. The `encodeURIComponent` call ensures the slug is safe in a URL query string. If the fetch fails for any reason (network error, server down, non-OK status), the error is swallowed silently — the widget simply stays in its initial state, which is fine. ### 2.4 — Sending a vote ```tsx title="src/components/Reaction/index.tsx" const handleVote = useCallback( async (vote: string) => { setSubmitError(false); try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, vote }), }); if (!res.ok) { setSubmitError(true); return; } const data = await res.json(); setCounts(data); setVoted(vote); try { localStorage.setItem(storageKey, vote); } catch {} } catch { setSubmitError(true); } }, [slug, apiUrl, storageKey], ); ``` `handleVote` POSTs `{ slug, vote }` as JSON. On success, it updates the displayed counts with the fresh values returned by the server, sets `voted` to the chosen value (switching the UI from "vote buttons" to "thank you" state), and saves the vote to `localStorage`. Any failure — a non-OK response or a thrown `fetch` — sets `submitError`, which surfaces the retry message in the render below; `localStorage` write failures are swallowed since the vote itself already went through. `useCallback` memoizes the function so it does not get recreated on every render, which is important since it is passed as an `onClick` prop. ### 2.5 — Rendering ```tsx title="src/components/Reaction/index.tsx" if (!slug) return null; return (
{!voted ? ( <> Was this article helpful?
{submitError && ( Could not save your vote — please try again. )} ) : (
{voted === "helpful" ? "Glad it helped! 🙌" : "Thanks for the feedback!"} {counts && ( 👍 {counts.helpful} 👎 {counts.not_helpful} )}
)}
); } ``` The rendering has two states. Before voting (`!voted`): the question, two buttons, and — only when `submitError` is set — a retry message under them. After voting (`voted` is set): a thank-you message personalized to the vote choice, plus the vote counts — which are now visible since the reader has expressed their opinion. The `aria-label` attributes make the buttons accessible to screen readers: emoji-only labels like "👍 Helpful" are not always read correctly by screen readers, so an explicit label helps. The `Props` and `Counts` interfaces at the top are the whole contract: the compiler flags a wrong `metadata` shape or a misused count field at build time, so there is no runtime `PropTypes` check to add. *** ## Step 3 — The CSS module Create file `styles.module.css` in `src/components/Reaction/`. **src/components/Reaction/styles.module.css** ```css /* * ⚠️ DUPLICATION — Reaction/styles.module.css and TriedIt/styles.module.css are * the SAME FILE, byte for byte, except that Reaction adds .submitError. Two * components, one widget: a question, a row of buttons, a thank-you state and * a count. They were tokenised identically here so they stay in step, but the * real fix is to have one of them import the other's stylesheet (CSS modules * support `composes: … from "../Reaction/styles.module.css"`), or to extract a * shared Feedback component. Flagged, not done: that is a refactor decision, * not a styling one. * * Fixed while tokenising: the hover filled the button with solid primary and * set `color: white` — a literal, and the only white text on terracotta in * this part of the site. It now uses --mk-on-line, which flips with the theme * (in dark mode the primary lightens, so white-on-it fails contrast). */ .container { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; padding: 0.9rem 1.25rem; margin: 1.5rem 0 0.5rem; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); background: var(--mk-surface); box-shadow: var(--mk-shadow-1); } .question { font-weight: 600; color: var(--mk-ink); white-space: nowrap; } .buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; } .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.85rem; font-family: inherit; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); background: transparent; color: var(--mk-ink); font-size: 0.875rem; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .mascot { width: 1.5rem; height: 1.5rem; object-fit: contain; } .btn:hover { background-color: var(--mk-primary); border-color: var(--mk-primary); color: var(--mk-on-line); } .btn:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 2px; } .btnNeutral:hover { background-color: var(--mk-n-300); border-color: var(--mk-n-300); color: var(--mk-n-900); } .thanks { display: flex; align-items: center; flex-wrap: wrap; gap: 1rem; } .thanksMsg { font-weight: 600; color: var(--mk-ink); } .counts { display: flex; gap: 0.75rem; font-size: 0.875rem; color: var(--mk-ink-soft); font-variant-numeric: tabular-nums; } .submitError { font-size: 0.8rem; color: var(--mk-danger-line); width: 100%; } ``` The outer `container` is a flex row with wrapping, so it collapses gracefully on narrow screens. The colors use Docusaurus CSS custom properties (`--ifm-*`), which means the widget automatically adapts to light mode, dark mode, and any custom Docusaurus theme without writing a single media query or duplicate rule. The "Helpful" button turns the primary theme color on hover; the "Not really" button turns a neutral grey. The 0.15 s transition makes the hover feel responsive without being flashy. *** ## Step 4 — Injecting the widget via swizzling **Swizzling** is Docusaurus's mechanism for replacing a built-in theme component with your own version. We want to inject `` at the bottom of every blog post page — and the right place to do that is `BlogPostItem`, the component that wraps the content and footer of every post. ### 4.1 — Run the swizzle command ```bash yarn run swizzle @docusaurus/theme-classic BlogPostItem --wrap ``` > **Wrap vs. Eject:** > > The `--wrap` flag creates a thin wrapper around the original component rather than copying the entire source. However, for `BlogPostItem` we want fine-grained control over the layout (to place `` exactly where we want it, between the content and the footer). In practice, you will likely eject, not wrap. Run the command without `--wrap` and Docusaurus will ask you to confirm; choose **Eject**. This creates `src/theme/BlogPostItem/index.js` with the original source. Open it. ### 4.2 — Modify the swizzled file You need to make three changes: import the component, detect whether we are on a single-post page, and render `` conditionally. Here is the relevant portion: ```javascript title="src/theme/BlogPostItem/index.js" {1,8,17-23} import Reaction from "@site/src/components/Reaction"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import BlogPostItemContainer from "@theme/BlogPostItem/Container"; import BlogPostItemContent from "@theme/BlogPostItem/Content"; import BlogPostItemFooter from "@theme/BlogPostItem/Footer"; import BlogPostItemHeader from "@theme/BlogPostItem/Header"; function useContainerClassName() { const { isBlogPostPage } = useBlogPost(); return !isBlogPostPage ? "margin-bottom--xl" : undefined; } export default function BlogPostItem({ children, className }) { const { metadata, isBlogPostPage } = useBlogPost(); const containerClassName = useContainerClassName(); return ( {children} {isBlogPostPage && ( )} ); } ``` The key guard is `isBlogPostPage`. Docusaurus renders `BlogPostItem` in two contexts: 1. **Blog list pages** — where article cards are shown in a grid or feed. 2. **Single post pages** — where the full article is displayed. Without the guard, a reaction widget would appear under every article card on the blog homepage, which is not what we want. `isBlogPostPage` is `true` only when you are reading a single post, so the widget shows up in exactly the right place. > **Placement matters:** > > If you have other custom components (a Bluesky share button, [related posts](/blog/docusaurus-relatedposts), etc.), the order here defines the visual order at the bottom of every post. Adjust to your preference. ### 4.3 — The complete swizzled file ON MY SITE > **:** > > Below is the contents of the index.js file on my blog. I'm sharing it as an example and for comparison, since I use many more React components than just the one we just covered in this post. **src/theme/BlogPostItem/index.js** ```javascript import ArticleActions from "@site/src/components/Blog/ArticleActions"; import PropTypes from "prop-types"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import BlogPostItemContainer from "@theme/BlogPostItem/Container"; import BlogPostItemContent from "@theme/BlogPostItem/Content"; import BlogPostItemFooter from "@theme/BlogPostItem/Footer"; import BlogPostItemHeader from "@theme/BlogPostItem/Header"; import Reaction from "@site/src/components/Reaction"; import TriedIt from "@site/src/components/TriedIt"; import TypoReport from "@site/src/components/TypoReport"; import clsx from "clsx"; // Our posts components import RelatedPosts from "@site/src/components/Blog/RelatedPosts"; // Locale awareness — both render nothing on the English site unless a translation exists. import TranslationNotice from "@site/src/components/Blog/TranslationNotice"; // Our Bluesky component import Bluesky from "@site/src/components/Bluesky"; // apply a bottom margin in list view function useContainerClassName() { const { isBlogPostPage } = useBlogPost(); return !isBlogPostPage ? "margin-bottom--xl" : undefined; } export default function BlogPostItem({ children, className }) { // We need to retrieve the isBlogPostPage flag const { metadata, isBlogPostPage } = useBlogPost(); const { frontMatter } = metadata; const containerClassName = useContainerClassName(); // These actions only make sense for the article the reader is actually on, // never for a card in a list view. const actions = isBlogPostPage ? : null; return ( <> {/* Above the content, never inside the translated Markdown file: the banner must not be something the translator can mangle, and the flag must be reachable before the reader has scrolled through prose they may not want to read. */} {/* The flag badge used to sit here too. Removed on 2026-09-16: the sentence below says the same thing in words, and a badge that duplicates it is noise at the very place a reader is trying to start the article. */} {isBlogPostPage && } {children} {/* Only display our RelatedPosts and Bluesky components on the post page; not the blog view */} {isBlogPostPage && ( <> {/* Bluesky comes first: it and RelatedPosts are both exit points (off-site vs. another post), and whichever renders first wins the click of a reader who just finished and is ready to act. Bluesky engagement is the current priority ask. */} {/* Readers who reach the end of an article should meet the next one to read before the reaction, comment and typo report blocks. Buried under those widgets, these links were never seen. */} {frontMatter.tried_it !== false && } )} ); } BlogPostItem.propTypes = { children: PropTypes.node, className: PropTypes.string, }; ``` *** ## Step 5 — The admin dashboard The dashboard is a standard Docusaurus page (not a blog post) that calls the admin endpoint of `reactions.php` and presents the data in a table with per-article approval bars. Create `src/pages/reactions-dashboard.js` with this content: **src/pages/reactions-dashboard.js** ```javascript import { useState, useEffect, useCallback } from "react"; import PropTypes from "prop-types"; import Layout from "@theme/Layout"; import Head from "@docusaurus/Head"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./reactions-dashboard.module.css"; // ── Data helpers ───────────────────────────────────────────────────────────── function computeTotals(data) { let totalHelpful = 0; let totalNot = 0; const rows = Object.entries(data) .map(([slug, counts]) => { const helpful = counts.helpful ?? 0; const notHelpful = counts.not_helpful ?? 0; const total = helpful + notHelpful; const ratio = total > 0 ? Math.round((helpful / total) * 100) : 0; totalHelpful += helpful; totalNot += notHelpful; return { slug, helpful, notHelpful, total, ratio }; }) .sort((a, b) => b.helpful - a.helpful || b.total - a.total); const grandTotal = totalHelpful + totalNot; const approval = grandTotal > 0 ? Math.round((totalHelpful / grandTotal) * 100) : 0; return { rows, totalHelpful, totalNot, grandTotal, approval }; } // ── Sub-components ─────────────────────────────────────────────────────────── function SummaryCards({ totalHelpful, totalNot, grandTotal, approval }) { return (
{grandTotal}
Total votes
👍 {totalHelpful}
Helpful
👎 {totalNot}
Not helpful
{approval}%
Approval rate
); } SummaryCards.propTypes = { totalHelpful: PropTypes.number.isRequired, totalNot: PropTypes.number.isRequired, grandTotal: PropTypes.number.isRequired, approval: PropTypes.number.isRequired, }; function ApprovalBar({ ratio }) { return (
{ratio}%
); } ApprovalBar.propTypes = { ratio: PropTypes.number.isRequired, }; function ReactionsTable({ rows, siteUrl }) { if (rows.length === 0) { return

No reactions recorded yet.

; } return ( {rows.map(({ slug, helpful, notHelpful, total, ratio }) => ( ))}
Article 👍 👎 Total Approval
{slug} {helpful} {notHelpful} {total}
); } ReactionsTable.propTypes = { rows: PropTypes.arrayOf( PropTypes.shape({ slug: PropTypes.string.isRequired, helpful: PropTypes.number.isRequired, notHelpful: PropTypes.number.isRequired, total: PropTypes.number.isRequired, ratio: PropTypes.number.isRequired, }), ).isRequired, siteUrl: PropTypes.string.isRequired, }; function AuthForm({ onSubmit }) { const [token, setToken] = useState(""); return (

Enter your admin token to view the dashboard.

{ e.preventDefault(); onSubmit(token.trim()); }} > setToken(e.target.value)} autoFocus />
); } AuthForm.propTypes = { onSubmit: PropTypes.func.isRequired, }; const STORAGE_KEY = "reactions_admin_token"; // ── Page ───────────────────────────────────────────────────────────────────── export default function ReactionsDashboard() { const { siteConfig } = useDocusaurusContext(); const apiUrl = `${siteConfig.url}/api/reactions.php`; const [token, setToken] = useState(""); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); // Priority: URL hash → localStorage → empty (show form). // Must run in useEffect — localStorage is unavailable during SSR, // and React does not re-run useState initializers on client hydration. useEffect(() => { const initial = window.location.hash.slice(1) || localStorage.getItem(STORAGE_KEY) || ""; // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment above if (initial) setToken(initial); }, []); const fetchData = useCallback( async (t) => { if (!t) return; setLoading(true); setError(null); try { const res = await fetch(`${apiUrl}?admin=${encodeURIComponent(t)}`); if (res.status === 403) { localStorage.removeItem(STORAGE_KEY); setError("Invalid token."); setData(null); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); localStorage.setItem(STORAGE_KEY, t); setData(await res.json()); } catch (e) { setError(`Failed to load data: ${e.message}`); setData(null); } finally { setLoading(false); } }, [apiUrl], ); // Reactive sync: refetch whenever the token changes (login, restore from storage, hash link). useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- fetchData sets state, but this is a legitimate reaction to `token` changing, not derivable at render time if (token) fetchData(token); }, [token, fetchData]); const handleTokenSubmit = (t) => { setToken(t); fetchData(t); }; const handleForget = () => { localStorage.removeItem(STORAGE_KEY); setToken(""); setData(null); setError(null); }; const stats = data ? computeTotals(data) : null; return (

Reactions Dashboard

Per-article reader feedback — sorted by helpful count

{!token && } {token && loading &&

Loading…

} {token && error &&

{error}

} {stats && ( <> )}
); } ``` ### 5.1 — Authentication The page reads the admin token from the URL hash (`/reactions-dashboard#your-token`). If no hash is present, it renders an input form to enter the token manually. The token is never sent in the URL path or stored in `localStorage`. ```javascript title="src/pages/reactions-dashboard.js" const [token, setToken] = useState(() => { if (typeof window === "undefined") return ""; return window.location.hash.slice(1); }); ``` Passing the token in the hash is a practical choice: the hash is never sent to the server in the HTTP request, so it does not appear in server logs, and you can bookmark the URL. Fill your `ADMIN_TOKEN` to connect: > Screenshot — https\://www\.avonture.be/reactions-dashboard/ > > The reaction dashboard login page ### 5.2 — Data loading ```javascript title="src/pages/reactions-dashboard.js" const fetchData = useCallback(async (t) => { if (!t) return; setLoading(true); setError(null); try { const res = await fetch(`${apiUrl}?admin=${encodeURIComponent(t)}`); if (res.status === 403) { setError("Invalid token."); setData(null); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); setData(await res.json()); } catch (e) { setError(`Failed to load data: ${e.message}`); setData(null); } finally { setLoading(false); } }, [apiUrl]); ``` A `403` means the token is wrong and gets a clear "Invalid token." message. Any other non-OK status is treated as a generic error. The `finally` block always clears the loading state. ### 5.3 — Aggregation ```javascript title="src/pages/reactions-dashboard.js" function computeTotals(data) { let totalHelpful = 0; let totalNot = 0; const rows = Object.entries(data) .map(([slug, counts]) => { const helpful = counts.helpful ?? 0; const notHelpful = counts.not_helpful ?? 0; const total = helpful + notHelpful; const ratio = total > 0 ? Math.round((helpful / total) * 100) : 0; totalHelpful += helpful; totalNot += notHelpful; return { slug, helpful, notHelpful, total, ratio }; }) .sort((a, b) => b.helpful - a.helpful || b.total - a.total); const grandTotal = totalHelpful + totalNot; const approval = grandTotal > 0 ? Math.round((totalHelpful / grandTotal) * 100) : 0; return { rows, totalHelpful, totalNot, grandTotal, approval }; } ``` `computeTotals` iterates over every article in `reactions-data.json`, computes a per-article approval ratio, and sorts by helpful count descending (ties broken by total votes). The grand totals at the end power the summary cards at the top of the page. And here is what you can get: > Screenshot — https\://www\.avonture.be/reactions-dashboard/ > > The reaction dashboard *** ## All Files at a Glance Here is the full set of files involved, grouped for easy installation (rendered with my [code snippets component](/blog/docusaurus-snippets)). > **... excluding src/theme/BlogPostItem/index.js:** > > In the list below, I've not included `src/theme/BlogPostItem/index.js` because, as said, that file will vary between my blog and yours. Please refer to **Step 4** and create the file manually. **Project: Reaction widget** **api/.env** ``` ADMIN_EMAIL=your_email@your_domain ADMIN_TOKEN=change-me-to-a-long-random-string NO_REPLY=noreply@your_domain IP_HASH_SALT=change-me-to-another-long-random-string NONCE_SECRET=change-me-to-yet-another-long-random-string ``` **api/reactions.php** ```php $message]); exit; } // Sends an email notification for a new vote, with a per-article cooldown. function maybeNotify(string $slug, string $vote, array $counts): void { $throttleFile = __DIR__ . '/notifications.json'; $throttle = loadData($throttleFile); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) { return; } $articleUrl = SITE_URL . '/' . $slug; $dashboardUrl = SITE_URL . '/reactions-dashboard'; $total = $counts['helpful'] + $counts['not_helpful']; $ratio = $total > 0 ? round($counts['helpful'] / $total * 100) : 0; $subject = "[Blog] New reaction on: $slug"; $body = implode("\n", [ "A reader just reacted to one of your blog posts.", "", "Article : $articleUrl", "Vote : $vote", "Helpful : {$counts['helpful']}", "Not helpful : {$counts['not_helpful']}", "Approval : {$ratio}%", "", "View full dashboard: $dashboardUrl", ]); $headers = implode("\r\n", [ "From: " . NO_REPLY, "Reply-To: " . NO_REPLY, "Content-Type: text/plain; charset=utf-8", ]); if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($throttleFile, $throttle); } } // ── Parse input ─────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; $slug = ''; $vote = ''; if ($method === 'GET') { // Admin request: return all reaction data if (array_key_exists('admin', $_GET)) { if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) { jsonError(403, 'Forbidden'); } echo json_encode(loadData(__DIR__ . '/reactions-data.json')); exit; } $slug = sanitizeSlug($_GET['slug'] ?? ''); } elseif ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true) ?? []; $slug = sanitizeSlug($body['slug'] ?? ''); $vote = $body['vote'] ?? ''; } else { jsonError(405, 'Method not allowed'); } if ($slug === '') { jsonError(400, 'Missing slug'); } // ── Load data ───────────────────────────────────────────────────────────────── $dataFile = __DIR__ . '/reactions-data.json'; $data = loadData($dataFile); if (!isset($data[$slug])) { $data[$slug] = ['helpful' => 0, 'not_helpful' => 0]; } // ── Record vote ─────────────────────────────────────────────────────────────── if ($method === 'POST') { if ($vote === 'helpful') { $data[$slug]['helpful']++; } elseif ($vote === 'not_helpful') { $data[$slug]['not_helpful']++; } else { jsonError(400, 'Invalid vote value'); } saveData($dataFile, $data); maybeNotify($slug, $vote, $data[$slug]); } echo json_encode($data[$slug]); ``` **src/components/Reaction/index.tsx** ```typescript import { useState, useEffect, useCallback, type JSX } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import useBaseUrl from "@docusaurus/useBaseUrl"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata?: { permalink?: string; }; } interface Counts { helpful: number; not_helpful: number; } export default function Reaction({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/reactions.php`; const storageKey = `reaction_${slug}`; const mascotYesUrl = useBaseUrl("/img/reaction-mascot-yes.webp"); const mascotNoUrl = useBaseUrl("/img/reaction-mascot-no.webp"); const [counts, setCounts] = useState(null); // null until the client-side effect runs — avoids SSR/hydration mismatch (#418). // If voted were read in useState(), SSR would produce null (no localStorage) // while the client would produce "helpful", causing a React tree mismatch. const [voted, setVoted] = useState(null); const [submitError, setSubmitError] = useState(false); useEffect(() => { try { const stored = localStorage.getItem(storageKey); // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment on `voted` above if (stored) setVoted(stored); } catch {} }, [storageKey]); useEffect(() => { if (!slug) return; fetch(`${apiUrl}?slug=${encodeURIComponent(slug)}`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data) setCounts(data); }) .catch(() => {}); }, [slug, apiUrl]); const handleVote = useCallback( async (vote: string) => { setSubmitError(false); try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, vote }), }); if (!res.ok) { setSubmitError(true); return; } const data = await res.json(); setCounts(data); setVoted(vote); try { localStorage.setItem(storageKey, vote); } catch {} } catch { setSubmitError(true); } }, [slug, apiUrl, storageKey], ); if (!slug) return null; return (
{!voted ? ( <> Was this article helpful?
{submitError && ( Could not save your vote — please try again. )} ) : (
{voted === "helpful" ? translate({ id: "reaction.thanksHelpful", message: "Glad it helped! 🙌" }) : translate({ id: "reaction.thanksNotHelpful", message: "Thanks for the feedback!", })} {counts && ( 👍 {counts.helpful} 👎 {counts.not_helpful} )}
)}
); } ``` **src/components/Reaction/styles.module.css** ```css /* * ⚠️ DUPLICATION — Reaction/styles.module.css and TriedIt/styles.module.css are * the SAME FILE, byte for byte, except that Reaction adds .submitError. Two * components, one widget: a question, a row of buttons, a thank-you state and * a count. They were tokenised identically here so they stay in step, but the * real fix is to have one of them import the other's stylesheet (CSS modules * support `composes: … from "../Reaction/styles.module.css"`), or to extract a * shared Feedback component. Flagged, not done: that is a refactor decision, * not a styling one. * * Fixed while tokenising: the hover filled the button with solid primary and * set `color: white` — a literal, and the only white text on terracotta in * this part of the site. It now uses --mk-on-line, which flips with the theme * (in dark mode the primary lightens, so white-on-it fails contrast). */ .container { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; padding: 0.9rem 1.25rem; margin: 1.5rem 0 0.5rem; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); background: var(--mk-surface); box-shadow: var(--mk-shadow-1); } .question { font-weight: 600; color: var(--mk-ink); white-space: nowrap; } .buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; } .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.85rem; font-family: inherit; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); background: transparent; color: var(--mk-ink); font-size: 0.875rem; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .mascot { width: 1.5rem; height: 1.5rem; object-fit: contain; } .btn:hover { background-color: var(--mk-primary); border-color: var(--mk-primary); color: var(--mk-on-line); } .btn:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 2px; } .btnNeutral:hover { background-color: var(--mk-n-300); border-color: var(--mk-n-300); color: var(--mk-n-900); } .thanks { display: flex; align-items: center; flex-wrap: wrap; gap: 1rem; } .thanksMsg { font-weight: 600; color: var(--mk-ink); } .counts { display: flex; gap: 0.75rem; font-size: 0.875rem; color: var(--mk-ink-soft); font-variant-numeric: tabular-nums; } .submitError { font-size: 0.8rem; color: var(--mk-danger-line); width: 100%; } ``` **src/pages/reactions-dashboard.js** ```javascript import { useState, useEffect, useCallback } from "react"; import PropTypes from "prop-types"; import Layout from "@theme/Layout"; import Head from "@docusaurus/Head"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./reactions-dashboard.module.css"; // ── Data helpers ───────────────────────────────────────────────────────────── function computeTotals(data) { let totalHelpful = 0; let totalNot = 0; const rows = Object.entries(data) .map(([slug, counts]) => { const helpful = counts.helpful ?? 0; const notHelpful = counts.not_helpful ?? 0; const total = helpful + notHelpful; const ratio = total > 0 ? Math.round((helpful / total) * 100) : 0; totalHelpful += helpful; totalNot += notHelpful; return { slug, helpful, notHelpful, total, ratio }; }) .sort((a, b) => b.helpful - a.helpful || b.total - a.total); const grandTotal = totalHelpful + totalNot; const approval = grandTotal > 0 ? Math.round((totalHelpful / grandTotal) * 100) : 0; return { rows, totalHelpful, totalNot, grandTotal, approval }; } // ── Sub-components ─────────────────────────────────────────────────────────── function SummaryCards({ totalHelpful, totalNot, grandTotal, approval }) { return (
{grandTotal}
Total votes
👍 {totalHelpful}
Helpful
👎 {totalNot}
Not helpful
{approval}%
Approval rate
); } SummaryCards.propTypes = { totalHelpful: PropTypes.number.isRequired, totalNot: PropTypes.number.isRequired, grandTotal: PropTypes.number.isRequired, approval: PropTypes.number.isRequired, }; function ApprovalBar({ ratio }) { return (
{ratio}%
); } ApprovalBar.propTypes = { ratio: PropTypes.number.isRequired, }; function ReactionsTable({ rows, siteUrl }) { if (rows.length === 0) { return

No reactions recorded yet.

; } return ( {rows.map(({ slug, helpful, notHelpful, total, ratio }) => ( ))}
Article 👍 👎 Total Approval
{slug} {helpful} {notHelpful} {total}
); } ReactionsTable.propTypes = { rows: PropTypes.arrayOf( PropTypes.shape({ slug: PropTypes.string.isRequired, helpful: PropTypes.number.isRequired, notHelpful: PropTypes.number.isRequired, total: PropTypes.number.isRequired, ratio: PropTypes.number.isRequired, }), ).isRequired, siteUrl: PropTypes.string.isRequired, }; function AuthForm({ onSubmit }) { const [token, setToken] = useState(""); return (

Enter your admin token to view the dashboard.

{ e.preventDefault(); onSubmit(token.trim()); }} > setToken(e.target.value)} autoFocus />
); } AuthForm.propTypes = { onSubmit: PropTypes.func.isRequired, }; const STORAGE_KEY = "reactions_admin_token"; // ── Page ───────────────────────────────────────────────────────────────────── export default function ReactionsDashboard() { const { siteConfig } = useDocusaurusContext(); const apiUrl = `${siteConfig.url}/api/reactions.php`; const [token, setToken] = useState(""); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); // Priority: URL hash → localStorage → empty (show form). // Must run in useEffect — localStorage is unavailable during SSR, // and React does not re-run useState initializers on client hydration. useEffect(() => { const initial = window.location.hash.slice(1) || localStorage.getItem(STORAGE_KEY) || ""; // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment above if (initial) setToken(initial); }, []); const fetchData = useCallback( async (t) => { if (!t) return; setLoading(true); setError(null); try { const res = await fetch(`${apiUrl}?admin=${encodeURIComponent(t)}`); if (res.status === 403) { localStorage.removeItem(STORAGE_KEY); setError("Invalid token."); setData(null); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); localStorage.setItem(STORAGE_KEY, t); setData(await res.json()); } catch (e) { setError(`Failed to load data: ${e.message}`); setData(null); } finally { setLoading(false); } }, [apiUrl], ); // Reactive sync: refetch whenever the token changes (login, restore from storage, hash link). useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- fetchData sets state, but this is a legitimate reaction to `token` changing, not derivable at render time if (token) fetchData(token); }, [token, fetchData]); const handleTokenSubmit = (t) => { setToken(t); fetchData(t); }; const handleForget = () => { localStorage.removeItem(STORAGE_KEY); setToken(""); setData(null); setError(null); }; const stats = data ? computeTotals(data) : null; return (

Reactions Dashboard

Per-article reader feedback — sorted by helpful count

{!token && } {token && loading &&

Loading…

} {token && error &&

{error}

} {stats && ( <> )}
); } ``` *** ## Security Considerations A few things to keep in mind before deploying: **Change the `ADMIN_TOKEN`.** The example in the repository uses a placeholder. Generate your own with `openssl rand -base64 32` and update the constant before deploying. Anyone with this token can read every vote ever recorded. **`reactions-data.json` and `notifications.json` should not be publicly accessible.** If your web server serves the `api/` folder directly, make sure these JSON files are not downloadable by just navigating to them. Add an `.htaccess` rule to block direct access to `*.json` files inside that directory: ```apacheconf title="api/.htaccess" Require all denied ``` **The PHP script does not authenticate voters.** Anyone can POST multiple votes for the same article from different clients. `localStorage` only prevents double voting from the same browser, not from scripts or different devices. For a personal blog, this is an acceptable trade-off — you get signal, not precision polling data. **File permissions.** Make sure `api/reactions-data.json` and `api/notifications.json` are writable by the web server user. The script creates them on first use, so the `api/` directory itself must be writable. On Linux: `chmod 775 api/` and ensure the web server user owns or is in the group of that folder. *** ## Testing It Once the files are in place: 1. **Start the Docusaurus dev server**: `yarn start` 2. Open any blog post. You should see the "Was this article helpful?" widget at the bottom. 3. Click one of the buttons. The widget should switch to the thank-you state. 4. Refresh the page. The widget should still show the thank-you state (loaded from `localStorage`). 5. Open a different browser (or an incognito window) and reload the same article. The vote counts from step 3 should be visible after voting. 6. Visit `/reactions-dashboard` and enter your admin token. The article you just voted on should appear in the table. > **The dashboard URL:** > > You can bookmark `/reactions-dashboard#your-token` to open the dashboard directly. The token in the hash fragment is never sent to the server in the HTTP request, so it does not appear in server access logs. *** ## Conclusion The entire system is about 250 lines of code across five files, has no external dependencies beyond what Docusaurus already ships, and requires nothing on the infrastructure side except a server capable of running PHP. What I like most about this approach is that every byte of data lives in your own files, under your own control. Votes are stored in a plain JSON file you can open with any text editor, back up with any tool, and migrate to any other system whenever you want. No SaaS subscription to cancel, no API key to renew, no privacy policy to update because someone else's SDK added a tracker. If you build on this and add features — per-section reactions, reader questions, [outdated flags](/blog/docusaurus-old-notice) — the same pattern scales naturally: one PHP file per feature, one React component, one JSON store. --- # 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 `` 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/.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 # 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 `` 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/.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 `` 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.** `` 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 — `` for a collapsible code block, `` for a callout, `` 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 `
` — 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/.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 `` // 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 `` (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 // . 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 Enter`). 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 ``/`` 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 `` 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): // `default` inside a paragraph. children is already // the literal default text — keep it, drop the wrapper. Var(node) { return node.children || []; }, // `textdefaulttext` (see Code.js) — one // code span mixing literal text and a ``. The tree walk is bottom-up // (see transformChildren below), so `node.children` here already has each // nested `` resolved to its plain default text; flatten it into one // `inlineCode`, same as a literal ``/`` 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; }, // / 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/.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) — `
`, // `

`, ``, ``, ``, ``… 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 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 []; // `` 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/` → `/blog/.md`), plus a site-wide `/llms.txt` index * and a per-series `/llms/.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//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/.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 [ "", "", ].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/.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/.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//`) while `permalinkFor()` // builds a bare `/blog/` — 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 ``,** 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 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 , // 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 ( ); } ``` **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/` 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 `` 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 [``](/blog/docusaurus-snippets) accordions that started this whole thing are new to you, that component is worth its own read. --- # Shake Your Phone, Startle the Meerkat > A ninth easter egg for this blog, built for readers on a phone: shake it — in Chrome or in the installed PWA — and the meerkat mascot takes over the full screen, startled, for about two seconds. Covers the devicemotion 'jerk' math behind shake detection, the three-phase overlay animation, how to test it from a desktop DevTools console with no accelerometer at all, and a real bug caught along the way — an AI image generator that drew a transparent-background placeholder as literal opaque pixels instead of real alpha. --- ![Shake Your Phone, Startle the Meerkat](/img/v2/shaked_meerkat.webp) **TL;DR** This blog already hides [eight small easter eggs](/blog/docusaurus-easter-eggs), but every one of them assumes a keyboard or an open DevTools panel — no use to a reader on a phone. This adds a ninth: shake the phone and the mascot appears full-screen, startled, for about two seconds. Covers the shake-detection math (a "jerk" threshold computed between consecutive `devicemotion` samples), the three-phase overlay animation (pop in, tremble, fade out), how to trigger and test it from a desktop DevTools console with no accelerometer involved, and a real bug caught along the way: an AI image generator that drew the checkerboard pattern used to *represent* transparency as literal opaque pixels, caught by reading the raw PNG bytes rather than trusting a preview. **Android only**, deliberately and permanently — iOS Safari's motion-permission prompt needs a tap, and every way to provide one conflicts with this site's "invisible until you stumble onto it" rule for easter eggs. Every easter egg on this blog so far assumes a keyboard: `CTRL`+`U` to view source, a ten-key Konami sequence, a `console.log` that only shows up with DevTools open. All reasonable on a laptop — and invisible to anyone reading on a phone, which on a technical blog is still a meaningful share of the traffic. A phone has no arrow keys, but it has something a laptop doesn't: an accelerometer, sitting one `window.addEventListener("devicemotion", …)` away, on any page served over HTTPS. So: shake the phone, and the mascot reacts. ## Shake Your Phone, See What Happens Visit this blog on Android — in Chrome, or in the installed PWA — and give the phone a real shake. This is what shows up, full screen, for about two seconds: > Screenshot — https\://www\.avonture.be/ > > ![A startled meerkat filling the screen after a phone shake](./images/shake_overlay.webp) No page reload, nothing to opt into first — the phone's own motion sensor did all the work. Here's how little code that actually takes. > **Nothing happening when you shake?:** > > Check that this site is allowed to read **Motion sensors** — tap the icon left of the address bar → Permissions. Chrome for Android can silently block this per site; see [How to Debug If the Shake Does Nothing](#how-to-debug-if-the-shake-does-nothing) below. ## Why It Works * **Shake detection needs one number, not three.** A `devicemotion` event reports acceleration on three axes (x, y, z); comparing each to a threshold barely works, because ordinary handling already wobbles all three constantly. What actually marks a deliberate shake is *how fast* those three numbers change between two consecutive readings — summed across axes and normalized by the time between samples, that collapses to a single "jerk" value with one clean threshold to tune. * **Android has no `requestPermission()` prompt; iOS does.** Ships Android-only as a result — deliberately, not temporarily; see [why](#why-ios-is-left-out-on-purpose) below. Android's own permission story turned out to have a wrinkle anyway, covered in [How to Debug If the Shake Does Nothing](#how-to-debug-if-the-shake-does-nothing). * **A cooldown, not a boolean flag, stops the retrigger.** The overlay stays on screen for over two seconds; without a minimum gap between triggers, the tail end of the same shake gesture would relaunch it immediately. Comparing timestamps is simpler than a lock, and it can't get stuck "on" if a cleanup step is ever skipped. * **It's one component, mounted once, globally** — [the same pattern already used for the Konami code and the tab-away favicon swap](/blog/docusaurus-easter-eggs): no new route, no per-page wiring, one more line in `Root.js`. * **The haptic buzz is a bonus, not a requirement.** A short `navigator.vibrate()` call adds a physical "yelp" alongside the visual one — and on browsers that silently ignore it outside a user gesture (which a sensor event isn't), the easter egg still works fine, just without the buzz. ## Why iOS Is Left Out, on Purpose iOS Safari 13+ requires an explicit `DeviceMotionEvent.requestPermission()` call, tied to a real tap, before it will hand over a single `devicemotion` reading — and that call cannot be made proactively on page load. Something has to trigger it, which leaves exactly two options, and both were rejected. The first is a visible control: a small "Enable shake effects" button or banner, shown once on iOS. It would work — but it would also be the first UI element on this entire blog that exists purely to ask for something before a reader has done anything to earn it. Every other easter egg here follows one rule, stated plainly in the article that introduced the first eight of them: > **The rule this blog holds every easter egg to:** > > Every easter egg lives in the console, the source, the response headers, or a state the visitor triggers on purpose. None of them ever appear in the normal reading flow. **If you're not looking for it, you'll never see it.** A button asking for motion-sensor access breaks that on sight, for every iOS visitor, whether or not they'd ever care about a shaking meerkat — a cost paid up front by 100% of them, for a payoff only the curious minority would ever trigger. That's a bad trade before it's even a design one. The second option is worse in a different way: skip the visible button and fire `requestPermission()` on the visitor's very first tap anywhere on the page — a nav link, a search icon, anything. Technically invisible in *this* site's own UI, since nothing rendered by this component would show up — but iOS still pops its own native "Allow Motion & Orientation Access?" system dialog the first time the call is made for an origin. A reader who tapped a menu item gets interrupted by a permission request with zero context for why a blog is suddenly asking about motion sensors. That's not surprise in the fun sense this site aims for; it's a non sequitur that degrades the very first interaction of an unrelated visit. Both paths spend something that belongs to every visitor — attention, trust, or a clean first tap — to fund a feature only some of them would ever want. An easter egg earns the right to exist by costing nothing to everyone who doesn't go looking for it; a permission prompt of any shape fails that test before the meerkat ever gets a chance to be startling. Android needed nothing to be asked, so Android is where this ships. iOS stays out, not because the code is hard, but because every way to make it possible there charges rent to a reader who came here to read, not to grant permissions. ## Installation The whole thing is two files plus one import. Given a transparent-background image of the mascot looking startled — more on how that image was actually produced, and the mistake that nearly shipped, in [Under the Hood](#under-the-hood-skip-this-if-you-just-want-to-use-it) below — the detection and the overlay live entirely in `index.tsx`: **src/components/ShakeEasterEgg/index.tsx** ```typescript import { useCallback, useEffect, useRef, useState, type JSX } from "react"; import clsx from "clsx"; import scaredImg from "@site/static/img/meerkat/suricate_scared.webp"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // "Jerk" (rate of change of acceleration) threshold that counts as a shake. // History: 28 (initial) fired from a mere tilt; 60 (first field fix) still // fired too easily across a full day of real-world phone use. Raise further // if it still fires too easily, lower it if a real shake stops triggering it. const SHAKE_JERK_THRESHOLD = 100; // Ignore devicemotion samples firing faster than this: some devices report // at 60Hz+, which would otherwise make every tiny jitter look like a spike. const MIN_SAMPLE_INTERVAL_MS = 100; // Minimum time between two triggers, so one shake gesture doesn't retrigger // itself while the overlay is still showing. const COOLDOWN_MS = 4000; // How long the overlay stays fully visible before it starts fading out. const VISIBLE_DURATION_MS = 2200; // Fade-out duration; kept in sync with the `overlay-out` keyframes below. const EXIT_DURATION_MS = 250; type Phase = "hidden" | "visible" | "leaving"; export default function ShakeEasterEgg(): JSX.Element | null { const [phase, setPhase] = useState("hidden"); const lastAcceleration = useRef<{ x: number; y: number; z: number } | null>(null); const lastSampleTime = useRef(0); const lastTriggerTime = useRef(0); const leaveTimeoutRef = useRef(undefined); const hideTimeoutRef = useRef(undefined); const clearTimers = useCallback(() => { window.clearTimeout(leaveTimeoutRef.current); window.clearTimeout(hideTimeoutRef.current); }, []); const dismiss = useCallback(() => { clearTimers(); setPhase("leaving"); hideTimeoutRef.current = window.setTimeout( () => setPhase("hidden"), EXIT_DURATION_MS, ); }, [clearTimers]); const trigger = useCallback(() => { clearTimers(); setPhase("visible"); // Best-effort haptic feedback. Browsers that require a user gesture for // navigator.vibrate() (devicemotion isn't one) will just ignore this. navigator.vibrate?.([40, 30, 40]); leaveTimeoutRef.current = window.setTimeout(() => { setPhase("leaving"); hideTimeoutRef.current = window.setTimeout( () => setPhase("hidden"), EXIT_DURATION_MS, ); }, VISIBLE_DURATION_MS); }, [clearTimers]); useEffect(() => { if (typeof window === "undefined" || !window.DeviceMotionEvent) { return undefined; } const handleMotion = (event: DeviceMotionEvent) => { const acceleration = event.accelerationIncludingGravity; // All three axes must be present: DeviceMotionEventAcceleration types each // as `number | null`, and the jerk maths below reads x, y and z. if ( !acceleration || acceleration.x === null || acceleration.y === null || acceleration.z === null ) return; const now = Date.now(); const deltaTime = now - lastSampleTime.current; if (deltaTime < MIN_SAMPLE_INTERVAL_MS) return; const { x, y, z } = acceleration; const previous = lastAcceleration.current; if (previous) { const jerk = ((Math.abs(x - previous.x) + Math.abs(y - previous.y) + Math.abs(z - previous.z)) / deltaTime) * 1000; if (jerk > SHAKE_JERK_THRESHOLD && now - lastTriggerTime.current > COOLDOWN_MS) { lastTriggerTime.current = now; trigger(); } } lastAcceleration.current = { x, y, z }; lastSampleTime.current = now; }; window.addEventListener("devicemotion", handleMotion); return () => window.removeEventListener("devicemotion", handleMotion); }, [trigger]); useEffect(() => clearTimers, [clearTimers]); if (phase === "hidden") { return null; } return ( ); } ``` And the full-screen overlay, its pop-in/tremble/fade animation, and the `prefers-reduced-motion` fallback live in the CSS module: **src/components/ShakeEasterEgg/styles.module.css** ```css .overlay { position: fixed; inset: 0; z-index: 10001; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; margin: 0; padding: 0; border: none; background: radial-gradient( circle at center, rgb(0, 0, 0, 0.65) 0%, rgb(0, 0, 0, 0.88) 100% ); cursor: pointer; animation: overlay-in 0.2s ease-out both; } .overlay.leaving { animation: overlay-out 0.25s ease-in both; } .meerkat { width: min(88vw, 80vh); height: auto; filter: drop-shadow(0 16px 40px rgb(0, 0, 0, 0.6)); animation: pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both, tremble 0.3s ease-in-out 0.35s 6; } @keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } @keyframes overlay-out { from { opacity: 1; } to { opacity: 0; } } @keyframes pop-in { from { transform: scale(0.4) rotate(-8deg); opacity: 0; } to { transform: scale(1) rotate(0deg); opacity: 1; } } @keyframes tremble { 0%, 100% { transform: translate(0, 0) rotate(0deg); } 20% { transform: translate(-6px, 3px) rotate(-3deg); } 40% { transform: translate(5px, -4px) rotate(3deg); } 60% { transform: translate(-4px, -2px) rotate(-2deg); } 80% { transform: translate(6px, 3px) rotate(2deg); } } @media (prefers-reduced-motion: reduce) { .overlay { animation: none; } .overlay.leaving { animation: none; opacity: 0; } .meerkat { animation: none; } } ``` Mounted once, globally, from `src/theme/Root.js` — right next to the Konami code component it borrows its shape from: ```jsx title="src/theme/Root.js (excerpt)" import ShakeEasterEgg from "@site/src/components/ShakeEasterEgg"; // ... return ( <> {children} ); ``` That's it — no new route, no config flag, no build step. ## Testing It Without Shaking Anything Standing up from a desk to physically shake a phone, twenty times in a row, to check a CSS timing tweak, gets old fast. `devicemotion` is just an event — nothing stops sending one synthetically from a browser console, on desktop, with no sensor anywhere in the loop: **Paste in DevTools console** ``` // Paste in your browser's DevTools console — desktop Chrome works fine, // no physical accelerometer needed — to trigger the shake overlay without // shaking anything. This is the exact snippet used to test the effect // before a single real device was ever involved. const fire = (x, y, z) => { window.dispatchEvent( new DeviceMotionEvent("devicemotion", { accelerationIncludingGravity: { x, y, z }, acceleration: { x, y, z }, interval: 16, }), ); }; fire(0, 0, 9.8); setTimeout(() => fire(25, -20, 15), 150); ``` Two calls, 150 ms apart, with a large enough jump between the two acceleration readings to cross the jerk threshold — that's the entire "physical" gesture, reduced to two numbers. This is also, honestly, how the screenshot above was produced: a headless browser, a mobile viewport, and this exact snippet dispatched through the page — not a real phone. ## How to Debug If the Shake Does Nothing Two checks, in order — both take under a minute and neither requires touching the code. ### 1. Allow Motion sensors for the site Chrome for Android has its own per-site **Motion sensors** permission, separate from anything this component controls. Tap the icon left of the address bar (padlock, "i", or a warning triangle if the certificate is self-signed) → **Permissions** → make sure **Motion sensors** is set to *Allow*, not *Block*. If it's not listed there, check **Chrome ⋮ → Settings → Site settings → Motion sensors** for a global block instead. Reload the page after changing it — this alone was the fix the one real device this was tested on needed. ### 2. Watch the raw sensor data When step 1 doesn't explain it, a small standalone diagnostic page settles the rest — no build step, no remote DevTools setup, just live numbers on the phone's own screen: **static/shake-debug.html** ```html Shake debug

Shake debug

DeviceMotionEvent?
isSecureContext?
location?
requestPermission (iOS-style)?
Permissions API query?
Waiting for the first devicemotion event…
events received0
x-
y-
z-
jerk (threshold 100)-
max jerk seen0
``` Visit `/shake-debug.html` on the same origin as the rest of the site and it reports, live: whether `DeviceMotionEvent` exists at all, `navigator.permissions.query({ name: "accelerometer" })`'s state (`"granted"` / `"denied"` / `"prompt"`), a running count of events received, the live `x`/`y`/`z` readings, and the computed jerk value against the current threshold. Three distinct failure signatures to read off it: * **No events at all, count stuck at 0** — the browser or OS is blocking the sensor entirely, or `DeviceMotionEvent` isn't supported (iOS without the permission flow, see the limitation above). * **Events arrive, but `x`/`y`/`z` stay `null`** — this is the Motion sensors permission from step 1, confirmed: the browser fires the event out of habit but withholds the data. * **Real numbers, but the max jerk never crosses the threshold** — not a bug, just a threshold calibrated for a different hand; see the tuning note below. This is also, worth admitting plainly, how the bug in step 1 was actually found in the first place: the shake did nothing on a real phone, this page showed events climbing with `x`/`y`/`z` stuck at `null`, and the permission query spelled out exactly why in one word — `"denied"`. ## Under the Hood (skip this if you just want to use it) ### The image was the hard part, not the code The component itself came together in one pass. Getting a **real** transparent-background image of the startled meerkat needed more care than the code — worth knowing if you're generating art with an AI image tool for anything that has to composite over other content. The site's mascot art is [generated with Google Gemini](/blog/gemini-meerkat), feeding an existing reference image so new poses stay visually consistent. For a full-screen overlay, "transparent background" isn't a style preference, it's a hard requirement: the file needs a real alpha channel, not just something that *looks* transparent in a preview. The prompt asked for a PNG export specifically, with an explicit line telling the model not to render the gray-and-white checkerboard pattern editors use to *represent* transparency as if it were part of the picture — that pattern is a UI convention, not pixels to draw. Two format facts make the result easy to verify, rather than trust a preview: * JPEG cannot store transparency at all, structurally — a PNG (or WebP) export is non-negotiable from the start. * A PNG's `IHDR` chunk carries one byte for exactly this: `6` means RGBA with a real alpha channel, `2` means plain RGB with none. Reading that byte — or just sampling a corner pixel and checking whether its alpha is actually `0` — settles the question without opening an editor. The final file passed both checks: alpha `6` in the `IHDR` chunk, corners at alpha `0`, character fully opaque — verified pixel by pixel rather than by eye. ### Tuning the jerk threshold on a real phone The first value shipped for `SHAKE_JERK_THRESHOLD` (`28`) was picked by feel, with a note admitting it hadn't been measured on a physical device. It didn't survive first contact with one: on a real Android phone, a quick *tilt* — no shaking involved — was already enough to cross it. A first correction raised it to `60`, which stopped the tilt-triggering — but a full day of carrying the phone normally still set it off repeatedly, which `60` alone hadn't revealed in a five-minute test. `100` is where it landed after that. There's no formula that gets this right from a desk, and apparently not from five minutes on a real phone either; a "jerk" threshold needs to survive actual carrying and handling over real time, not just a deliberate test shake, before it's trusted. ## Conclusion A ninth easter egg, and the first one this blog built for a phone rather than a keyboard: shake detection reduced to one "jerk" number, a full-screen overlay that respects `prefers-reduced-motion` and a tap to dismiss, and a way to test all of it without leaving a desk. The real lesson wasn't in the shake-detection code — it was in not trusting an image preview: a file can *look* transparent and still be fully opaque, and the only way to know for sure is to check the bytes. iOS staying out for good, [not for lack of trying but by design](#why-ios-is-left-out-on-purpose), is itself the reminder worth keeping: an easter egg only has to delight the people who go looking for it, never cost anything to the ones who don't — a bar a real feature doesn't get to duck under, but a hidden reward does. If you're running your own Docusaurus blog with a mascot of its own, the [Konami code and tab-away favicon swap](/blog/docusaurus-easter-eggs) are the desktop-side siblings of this one — same "mount once in `Root.js`" shape, no sensor, no permission trade-off to make. **What to tune** * **`SHAKE_JERK_THRESHOLD`** (default `100`, up from `28` then `60` after real-world use kept firing too easily) — raise it further if the egg still fires too easily, lower it if a deliberate shake does nothing * **`COOLDOWN_MS`** (default `4000`) — minimum time between two triggers * **`VISIBLE_DURATION_MS`** (default `2200`) / **`EXIT_DURATION_MS`** (default `250`) — how long the overlay stays up and how fast it fades; keep the CSS `overlay-out` duration in sync with the latter * All four live at the top of `src/components/ShakeEasterEgg/index.tsx` --- # Add a "Copy as Markdown" Button to Your Docusaurus Blog > Build a "Copy as Markdown" button and a "View raw" link for your Docusaurus blog posts — a build-time plugin that writes a plain-Markdown mirror next to every article, plus the tiny React component that fetches it and copies it to the clipboard. Full source included. --- ![Add a "Copy as Markdown" Button to Your Docusaurus Blog](/img/v2/copy-as-markdown.webp) **TL;DR** This guide adds a "Copy as Markdown" button and a "View raw" link to every post on a Docusaurus blog. Two small pieces make it work: a build-time plugin that writes a plain-Markdown mirror next to each article's HTML page (`/blog/my-post` → `/blog/my-post.md`), and a React component that fetches that mirror and copies it to the clipboard. No server, no database, about 90 lines of code in total — and readers, or the LLM they paste the URL into, get the article's full text without React, JSX, or any collapsed accordion the HTML page hides by default. Every time I want to paste one of my own articles into an LLM to ask it a question, I go through the same annoying ritual: open the page, select all, copy, paste, then spend a minute deleting the navigation menu, the "Was this helpful?" widget, and half a dozen stray UI labels that came along for the ride. The article's actual content — the part I wanted — is buried in there somewhere. A blog post's source is *already* Markdown. There is no reason a reader should have to fight the rendered HTML to get it back. So I built a small "Copy as Markdown" button that sits right in the article header: one click, and the plain-text source lands in the clipboard, ready to paste anywhere. This post walks through both pieces you need to reproduce it — a build-time mirror generator and the button itself — with the button's real, current source code included. **Quick Jump:** [What Gets Copied](#what-gets-copied) | [All Files at a Glance](#all-files-at-a-glance) ## What Gets Copied Here's the file the button actually fetches, produced by a `postBuild` step you'll write in a moment — this is the literal text that lands in the clipboard when a reader clicks the button: ```bash $ yarn build [SUCCESS] Generated static files in "build". [INFO] markdown-mirror-plugin: wrote 247 .md mirrors $ curl -s https://your-domain.tld/blog/docusaurus-go-top.md | head -n 13 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 `` 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. ``` Even the `` tag survives untouched — good enough for a human to skim or an LLM to parse. Notice the HTML comment at the very top, too: it's how the mirror points back to the real, fully rendered page. ## Why It's Just Two Moving Parts * A React button that fetches a URL derived from the current page's permalink and copies whatever text comes back — it has no idea what "Markdown" even is. * A build step that makes that URL actually respond with something worth copying: one plain-text file per post, sitting quietly next to the HTML page it mirrors. * Because the two only talk to each other over a plain URL, either can be swapped independently later — a smarter mirror generator, a fancier button — without touching the other file. * A missing mirror (wrong build, dev server, typo in the URL) degrades to a clear "Could not copy" message instead of a silent failure or a crash. ## Step 1 — Generate a Plain-Markdown Mirror at Build Time The button is only half the story: without a `.md` file for it to fetch, it always ends in the "Could not copy" state. That file needs to exist *before* the button is even useful, so build it first. Create `plugins/markdown-mirror-plugin/index.cjs`: **plugins/markdown-mirror-plugin/index.cjs** ```cjs // plugins/markdown-mirror-plugin/index.cjs // // Writes a plain-Markdown mirror of every published blog post next to its // HTML page: /blog/ -> /blog/.md // // Runs only during `yarn build` (postBuild), never during `yarn start`. const fs = require("fs"); const path = require("path"); const frontMatter = require("front-matter"); const BLOG_DIR = "blog"; 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; } // An HTML comment, not visible prose: hidden by any Markdown renderer, but // still plain text in a raw view or in whatever an LLM is handed — exactly // where a reader who lost track of the original page needs it. function buildMetadataComment(url, buildDate) { return [ "", "", ].join("\n"); } module.exports = function markdownMirrorPlugin() { return { name: "markdown-mirror-plugin", async postBuild({ siteDir, outDir, siteConfig, routesPaths }) { const blogDir = path.join(siteDir, BLOG_DIR); const knownRoutes = new Set(routesPaths); // One timestamp for the whole run, shared by every mirror it writes. const buildDate = new Date().toISOString(); let written = 0; for (const file of findPosts(blogDir)) { const raw = fs.readFileSync(file, "utf-8"); const { attributes, body } = frontMatter(raw); if (!attributes.slug) continue; const permalink = `/blog/${attributes.slug}`; // Cross-checking against Docusaurus's own routesPaths is what makes // draft: true posts fall out for free — no need to duplicate that // logic here. if (!knownRoutes.has(permalink)) continue; const url = `${siteConfig.url}${permalink}`; const content = body.replace("\n", "").trim() + "\n"; const markdown = buildMetadataComment(url, buildDate) + content; const outFile = path.join(outDir, "blog", `${attributes.slug}.md`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, markdown); written += 1; } console.log(`markdown-mirror-plugin: wrote ${written} .md mirrors`); }, }; }; ``` Four things worth calling out: ```javascript title="plugins/markdown-mirror-plugin/index.cjs" function buildMetadataComment(url, buildDate) { return [ "", "", ].join("\n"); } ``` This is the piece that answers a question every plain-text mirror eventually raises: *where did this come from?* An HTML comment is the right container for it — hidden the moment the file is pasted into anything that actually renders Markdown (Notion, Obsidian, a GitHub comment box), yet still plain text in a raw view or in whatever an LLM is handed, so the URL survives exactly where a reader who lost track of the original page needs it. `generated-at` uses one timestamp shared by every mirror in the run (computed once in `postBuild`, not per file), so it reflects "this build," not "this millisecond." ```javascript title="plugins/markdown-mirror-plugin/index.cjs" async postBuild({ siteDir, outDir, siteConfig, routesPaths }) { ``` `postBuild` is a Docusaurus [lifecycle hook](https://docusaurus.io/docs/api/plugin-methods/lifecycle-apis#postBuild) — it fires once, after `yarn build` finishes writing the HTML, and never during `yarn start`. That's exactly the timing you want: the mirror only needs to exist in production. `siteConfig.url` is what turns a bare permalink into the absolute URL the comment above needs. ```javascript title="plugins/markdown-mirror-plugin/index.cjs" if (!knownRoutes.has(permalink)) continue; ``` `routesPaths` is the list of URLs Docusaurus itself decided to actually publish. Cross-checking against it — instead of re-implementing "is this post a draft?" logic by hand — is what makes `draft: true` posts fall out of the mirror for free. ```javascript title="plugins/markdown-mirror-plugin/index.cjs" const content = body.replace("\n", "").trim() + "\n"; const markdown = buildMetadataComment(url, buildDate) + content; ``` The frontmatter block is stripped by the `front-matter` package before this line even runs; the `replace` here only removes the `` marker, which has no meaning outside Docusaurus's own summary-splitting logic. The metadata comment from earlier gets prepended last, right before the file is written. Wire it into your config: ```javascript title="docusaurus.config.js" const config = { // ... plugins: ["./plugins/markdown-mirror-plugin/index.cjs"], }; ``` > **front-matter is a small, dependency-free package:** > > `npm install front-matter` if your project doesn't already have it — it's a single-purpose YAML-frontmatter parser, not a full Markdown toolchain. > **This version is intentionally simple:** > > It copies your Markdown source through as-is, so any custom MDX component (a ``, a ``, your own widgets) shows up in the mirror as raw JSX text rather than a plain-Markdown equivalent. For a handful of components that's perfectly readable, as the mirror above shows. If your blog leans on dozens of custom components the way this one does, see "Under the Hood" further down for what a fuller version needs to handle. ## Step 2 — The CopyAsMarkdown Component This is the actual, current source running on this blog — not a simplified excerpt: **src/components/CopyAsMarkdown/index.tsx** ```typescript import { useState, useCallback, useEffect, type JSX } from "react"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata: { permalink: string; }; } type Status = "idle" | "copying" | "copied" | "error"; /** * "Copy as Markdown" / "View raw" — reads the plain-Markdown mirror that * plugins/markdown-export-plugin writes next to every post's HTML page * (`/blog/` → `/blog/.md`, see TODO 0082) and copies its full * content to the clipboard. Lets a reader — or an LLM they paste it into — * get the article's complete text without React, JSX, or the accordions the * HTML page collapses by default. * * Only meaningful on a real `yarn build` output: the mirror is written in * `postBuild`, so on `yarn start` the fetch below 404s and the button * reports "Could not copy" — the same graceful-failure path a mirror that's * missing for any other reason takes. */ export default function CopyAsMarkdown({ metadata }: Props): JSX.Element { const [status, setStatus] = useState("idle"); const mdUrl = `${metadata.permalink.replace(/\/$/, "")}.md`; const handleCopy = useCallback(async () => { setStatus("copying"); try { const res = await fetch(mdUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); await navigator.clipboard.writeText(text); setStatus("copied"); } catch (err) { console.error("CopyAsMarkdown: failed to copy", err); setStatus("error"); } }, [mdUrl]); useEffect(() => { if (status !== "copied" && status !== "error") return; const timer = setTimeout(() => setStatus("idle"), 2000); return () => clearTimeout(timer); }, [status]); return (
); } ``` ### 2.1 — State and the derived URL ```javascript title="src/components/CopyAsMarkdown/index.tsx" const [status, setStatus] = useState("idle"); // idle | copying | copied | error const mdUrl = `${metadata.permalink.replace(/\/$/, "")}.md`; ``` `status` is a small four-state machine that drives everything the button shows. `mdUrl` strips any trailing slash off the post's permalink and appends `.md` — matching exactly what the plugin from Step 1 writes to disk. ### 2.2 — The copy handler ```javascript title="src/components/CopyAsMarkdown/index.tsx" const handleCopy = useCallback(async () => { setStatus("copying"); try { const res = await fetch(mdUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); await navigator.clipboard.writeText(text); setStatus("copied"); } catch (err) { console.error("CopyAsMarkdown: failed to copy", err); setStatus("error"); } }, [mdUrl]); ``` A `fetch` for the mirror, a `writeText` to the clipboard, and a single `catch` around both. That one `catch` block is doing double duty: it catches a missing mirror (404, `!res.ok`) *and* a Clipboard API rejection (denied permission, insecure context) with the same "error" outcome — the reader doesn't need to know which one happened, only that the copy didn't work. > **The Clipboard API needs a secure context:** > > `navigator.clipboard` is only available over HTTPS, or on `localhost`. Test the button on a plain `http://` address on any other host and `writeText` will reject every time — that's the browser, not a bug in this component. ### 2.3 — Auto-reset ```javascript title="src/components/CopyAsMarkdown/index.tsx" useEffect(() => { if (status !== "copied" && status !== "error") return; const timer = setTimeout(() => setStatus("idle"), 2000); return () => clearTimeout(timer); }, [status]); ``` Once `status` lands on `copied` or `error`, this effect schedules a return to `idle` two seconds later, so the button doesn't stay stuck reading "✓ Copied" forever. The cleanup function cancels a pending timer if the reader clicks again before those two seconds are up. ### 2.4 — Rendering ```javascript title="src/components/CopyAsMarkdown/index.tsx" return (
View raw
); ``` The button's label is a straight ternary chain off `status`; it's disabled while a fetch is in flight so a reader can't fire off a second request mid-copy. The "View raw" link needs no JavaScript at all — it points straight at `mdUrl` and lets the browser handle it, `target="_blank"` and `rel="noopener noreferrer"` keeping the new tab from getting a handle back to this one. ## Step 3 — The CSS Module **src/components/CopyAsMarkdown/styles.module.css** ```css /* * No outer margin: this sits inside Blog/ArticleActions, which owns the row's * spacing. A margin here would stack with the row's and push the actions off * the date line they belong to. * * The gap is tighter than the row's (16px): "Copy as Markdown" and "View raw" * are two ways to get the same thing, so they read as a pair distinct from the * other, unrelated actions in the row (e.g. "Follow"). * * PAIRED FILE: .copyBtn below and Blog/Series's .markdownLink are deliberately * identical so the two "get this as plain Markdown" affordances read as one * family. Both were tokenised together in the same pass — change one, change * the other. */ .wrapper { display: inline-flex; align-items: center; gap: 8px; } .copyBtn { padding: 5px 12px; font-family: inherit; background: transparent; color: var(--mk-ink-soft); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; cursor: pointer; } .copyBtn:disabled { cursor: default; opacity: 0.7; } .copyBtn:hover:not(:disabled) { border-color: var(--mk-primary); color: var(--mk-primary); } /* * Bordered like .copyBtn, on purpose: as a bare link it sat closer to the button * than to anything else and still read as an unstyled third action rather than * as a button in its own right. */ .rawLink { padding: 5px 12px; color: var(--mk-ink-soft); text-decoration: none; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; } .rawLink:hover { color: var(--mk-primary); text-decoration: none; border-color: var(--mk-primary); } ``` Every color here is an Infima custom property (`--ifm-color-emphasis-*`), so the button adapts to light mode, dark mode, or a fully custom Docusaurus theme without a single hardcoded hex value or a `[data-theme="dark"]` override to maintain. ## Step 4 — Wire It Into the Blog Post Header Docusaurus renders your post content through a theme component called `BlogPostItem`. Getting the button to show up means swizzling it: ```bash yarn run swizzle @docusaurus/theme-classic BlogPostItem --eject ``` Confirm the eject when prompted — a `--wrap` wouldn't give you a place to insert the button between the header and the content. Open the resulting `src/theme/BlogPostItem/index.js` and add three things: the import, the `isBlogPostPage` guard (so the button never renders on a list-view card), and the component itself. ```javascript title="src/theme/BlogPostItem/index.js" {1,10} import CopyAsMarkdown from "@site/src/components/CopyAsMarkdown"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import BlogPostItemContainer from "@theme/BlogPostItem/Container"; import BlogPostItemContent from "@theme/BlogPostItem/Content"; import BlogPostItemFooter from "@theme/BlogPostItem/Footer"; import BlogPostItemHeader from "@theme/BlogPostItem/Header"; export default function BlogPostItem({ children, className }) { const { metadata, isBlogPostPage } = useBlogPost(); return ( {isBlogPostPage && } {children} ); } ``` `isBlogPostPage` is `true` only when a reader is looking at a single article, never on the blog's homepage or archive grid — the same guard used to place [the reader-reactions widget](/blog/docusaurus-reactions) and [the scroll-to-top button](/blog/docusaurus-go-top) correctly on this very blog. > **On my own site, it's threaded through the header instead:** > > My `BlogPostItem/index.js` passes `` down into `BlogPostItem/Header` as a prop rather than rendering it inline, because that header already juggles an AI-assisted badge and the author list. You only need that indirection if your own header does something similar — for most blogs, rendering it directly as shown above is simpler and just as correct. ## All Files at a Glance **Project: Copy as Markdown** **plugins/markdown-mirror-plugin/index.cjs** ```cjs // plugins/markdown-mirror-plugin/index.cjs // // Writes a plain-Markdown mirror of every published blog post next to its // HTML page: /blog/ -> /blog/.md // // Runs only during `yarn build` (postBuild), never during `yarn start`. const fs = require("fs"); const path = require("path"); const frontMatter = require("front-matter"); const BLOG_DIR = "blog"; 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; } // An HTML comment, not visible prose: hidden by any Markdown renderer, but // still plain text in a raw view or in whatever an LLM is handed — exactly // where a reader who lost track of the original page needs it. function buildMetadataComment(url, buildDate) { return [ "", "", ].join("\n"); } module.exports = function markdownMirrorPlugin() { return { name: "markdown-mirror-plugin", async postBuild({ siteDir, outDir, siteConfig, routesPaths }) { const blogDir = path.join(siteDir, BLOG_DIR); const knownRoutes = new Set(routesPaths); // One timestamp for the whole run, shared by every mirror it writes. const buildDate = new Date().toISOString(); let written = 0; for (const file of findPosts(blogDir)) { const raw = fs.readFileSync(file, "utf-8"); const { attributes, body } = frontMatter(raw); if (!attributes.slug) continue; const permalink = `/blog/${attributes.slug}`; // Cross-checking against Docusaurus's own routesPaths is what makes // draft: true posts fall out for free — no need to duplicate that // logic here. if (!knownRoutes.has(permalink)) continue; const url = `${siteConfig.url}${permalink}`; const content = body.replace("\n", "").trim() + "\n"; const markdown = buildMetadataComment(url, buildDate) + content; const outFile = path.join(outDir, "blog", `${attributes.slug}.md`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, markdown); written += 1; } console.log(`markdown-mirror-plugin: wrote ${written} .md mirrors`); }, }; }; ``` **src/components/CopyAsMarkdown/index.tsx** ```typescript import { useState, useCallback, useEffect, type JSX } from "react"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata: { permalink: string; }; } type Status = "idle" | "copying" | "copied" | "error"; /** * "Copy as Markdown" / "View raw" — reads the plain-Markdown mirror that * plugins/markdown-export-plugin writes next to every post's HTML page * (`/blog/` → `/blog/.md`, see TODO 0082) and copies its full * content to the clipboard. Lets a reader — or an LLM they paste it into — * get the article's complete text without React, JSX, or the accordions the * HTML page collapses by default. * * Only meaningful on a real `yarn build` output: the mirror is written in * `postBuild`, so on `yarn start` the fetch below 404s and the button * reports "Could not copy" — the same graceful-failure path a mirror that's * missing for any other reason takes. */ export default function CopyAsMarkdown({ metadata }: Props): JSX.Element { const [status, setStatus] = useState("idle"); const mdUrl = `${metadata.permalink.replace(/\/$/, "")}.md`; const handleCopy = useCallback(async () => { setStatus("copying"); try { const res = await fetch(mdUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); await navigator.clipboard.writeText(text); setStatus("copied"); } catch (err) { console.error("CopyAsMarkdown: failed to copy", err); setStatus("error"); } }, [mdUrl]); useEffect(() => { if (status !== "copied" && status !== "error") return; const timer = setTimeout(() => setStatus("idle"), 2000); return () => clearTimeout(timer); }, [status]); return (
View raw
); } ``` **src/components/CopyAsMarkdown/styles.module.css** ```css /* * No outer margin: this sits inside Blog/ArticleActions, which owns the row's * spacing. A margin here would stack with the row's and push the actions off * the date line they belong to. * * The gap is tighter than the row's (16px): "Copy as Markdown" and "View raw" * are two ways to get the same thing, so they read as a pair distinct from the * other, unrelated actions in the row (e.g. "Follow"). * * PAIRED FILE: .copyBtn below and Blog/Series's .markdownLink are deliberately * identical so the two "get this as plain Markdown" affordances read as one * family. Both were tokenised together in the same pass — change one, change * the other. */ .wrapper { display: inline-flex; align-items: center; gap: 8px; } .copyBtn { padding: 5px 12px; font-family: inherit; background: transparent; color: var(--mk-ink-soft); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; cursor: pointer; } .copyBtn:disabled { cursor: default; opacity: 0.7; } .copyBtn:hover:not(:disabled) { border-color: var(--mk-primary); color: var(--mk-primary); } /* * Bordered like .copyBtn, on purpose: as a bare link it sat closer to the button * than to anything else and still read as an unstyled third action rather than * as a button in its own right. */ .rawLink { padding: 5px 12px; color: var(--mk-ink-soft); text-decoration: none; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; } .rawLink:hover { color: var(--mk-primary); text-decoration: none; border-color: var(--mk-primary); } ``` `src/theme/BlogPostItem/index.js` isn't included above — as noted in Step 4, that file already exists on your site in some form, and you're editing it, not creating it from scratch. Rendered here with [the same code-snippets component](/blog/docusaurus-snippets) used throughout this article. ## Try It Yourself 1. Add the plugin to `docusaurus.config.js` and create the three files above. 2. Run `yarn build` — not `yarn start`. `postBuild` hooks never fire on the dev server, so the mirror genuinely doesn't exist until a real build runs. 3. Serve the output locally, e.g. `npx serve build`, and open any article. 4. Click **Copy as Markdown**, then paste into a text editor. You should see the plain Markdown source, `` tags and all. 5. Click **View raw**. The `.md` file should open as plain text in a new tab. > **Self-hosted on Apache? Watch the MIME type:** > > If step 5 triggers a download prompt instead of displaying the file, your server doesn't know `.md` is text. Add `AddType text/markdown .md` to your `.htaccess` (or the Nginx equivalent, a `types` block) so it's served inline instead. ## Under the Hood (skip this if you just want to use it) **Why the mirror can't collide with the post's own URL.** `/blog/my-post` is a *directory* containing an `index.html` — that's how Docusaurus serves clean URLs. `/blog/my-post.md` is a sibling *file* in that same directory. Same folder, different files, zero routing conflict. **Why `postBuild`, not a webpack loader or a dev-time route.** A loader would need to re-run the same MDX-to-text logic on every hot reload for no benefit — nobody is copying a post from a blog they're actively editing. Restricting the mirror to production keeps the dev server fast and the logic in exactly one place. **Where the simple version stops being enough.** The plugin in Step 1 passes every custom component through as literal JSX text. That's fine for the odd `` block, but it stops looking like Markdown fast once a post leans on dozens of components — code snippets, alert boxes, step-by-step cards, and so on. Doing that properly means parsing the MDX into an actual syntax tree and replacing each component node with a plain-Markdown equivalent, component by component. It's a legitimately bigger project than a button and a copy loop — the kind of thing this blog's own, much larger, degradation pipeline exists for — and a good candidate for a follow-up article on its own. **A frontmatter date isn't a string.** If you extend the metadata comment to also print the post's own `date:` field, don't string-concatenate it directly. YAML auto-types an unquoted `date: 2024-02-23` as a JS `Date` object, and `` `${date}` `` calls its verbose default `toString()` (`Mon Jul 27 2026 00:00:00 GMT+0000 (Coordinated Universal Time)`). Normalize it first: `date instanceof Date ? date.toISOString().slice(0, 10) : String(date)`. ## Conclusion Ninety-odd lines of code, split across a build plugin and a React component, is all it takes to stop fighting rendered HTML every time you want an article's plain text. The button doesn't know what Markdown is, and the plugin doesn't know what a button is — each does one small job, and the whole thing holds together because neither has to know about the other's internals. If you build on this, the natural next step is the one flagged in *Under the Hood*: teaching the mirror generator to actually degrade your custom components instead of passing them through as raw JSX. Until then, this version already solves the problem that started this whole thing — no more manual copy-paste surgery, for me or for anyone who wants to hand one of my articles to an LLM. --- # Ask My Blog: Turning 259 Articles Into 2,160 Searchable Questions > A local LLM reads every article on your blog and writes the questions a developer would actually type into a search bar, each one mapped to the exact heading that answers it. The result is a build-time question index searched with plain BM25 in the browser — no embeddings, no API key, no vector database. Full pipeline, plugin and component included. --- ![Ask My Blog: Turning 259 Articles Into 2,160 Searchable Questions](/img/v2/ask-my-blog.webp) **TL;DR** A site search only finds the words *you* wrote. A reader whose Docker image is too big types "my docker image is huge", not "optimizing layer caching" — and finds nothing. So I had a local Ollama model read all 259 of my articles and write, for each one, the 8 to 12 questions a developer would really type, each mapped to the heading that answers it. That produced 2,160 questions, served as a build-time index and searched in the browser with plain BM25. No embeddings, no vector database, no API key. Search on a personal blog is almost always a disappointment, and it took me a while to understand why. It is not the search engine's fault — mine indexes every word of every page perfectly well. The problem is that it can only find the words *I* wrote. I write "reducing the final image size through multi-stage builds". The reader in trouble types "my docker image is huge". Zero results. The article they needed is sitting right there, indexed, matched against a vocabulary they never used. The usual answer to that is embeddings: turn everything into vectors, compare meanings instead of words. But that means a model in the browser, or an API call per keystroke, for a static blog with no backend. There is a much cheaper way to bridge two vocabularies — and it starts with noticing that the bridge only has to be built **once**. **Quick Jump:** [What You Get](#what-you-get) | [How the Questions Are Generated](#generating-the-questions) ## What You Get The [`/faq` page](/faq) opens on a hub: topic cards, one per main tag — *Docker*, *AI*, *Bash*, *WSL*, and more. Each card shows how many questions are filed under it. Click one and you land on a page listing every question for that topic, each pointing at the exact article heading that answers it: > Screenshot — https\://www\.avonture.be/faq > > ![FAQ hub — topic cards with question counts per tag](./images/faq-hub.png) The same index also powers a search box on that page. Type a problem in your own words — "my docker image is huge", "fuzzy find a file in the terminal" — and you get questions back, not page titles. Each result points at the exact heading that answers it: > Screenshot — https\://www\.avonture.be/faq > > ![Search results — questions matching the query, each linked to the heading that answers it](./images/faq-search.png) That is the whole idea, and the rest of this article is how it is built. ## Why This Works Without a Model in the Browser The insight is small and it is the whole article: **the semantic work does not have to happen at search time.** * A reader's query and an article's prose are written in two different vocabularies. Something has to translate between them. * That translation is a fixed property of the article. It does not depend on who is searching, or when. So it can be computed once and stored. * Once a model has already phrased "how do I stop my Docker image being enormous?" and filed it next to the right heading, matching a reader's query against **that** sentence is a plain string-similarity problem. Twenty-year-old lexical ranking handles it perfectly. * What ships to the browser is therefore a list of sentences and a scoring function — not a model, not a vector index, not a network call. The build gets slower (about 11 seconds per article, once). Every reader after that gets an instant answer. ## Generating the Questions The generator asks a local [Ollama](/blog/ollama-installation) instance for questions about one article, and writes them into a sidecar file next to it — the same convention my [ELI5 tooltips](/blog/docusaurus-eli5-snippet-tooltips) already use for their own generated content: ```bash $ time yarn questions blog/2025/09/12/docusaurus-go-top/index.md --force ✅ Written: blog/2025/09/12/docusaurus-go-top/index.md.questions.json 8 questions. real 0m11.147s $ yarn questions:bulk --dry-run --limit 4 🔍 4 article(s) under blog (--limit 4). [SKIP] blog/2023/11/02/docker-php-run-script-or-website/index.md [SKIP] blog/2023/11/02/keepass-start-rdp/index.md [SKIP] blog/2023/11/02/welcome/index.md [SKIP] blog/2023/11/02/wslg-rpd-connection/index.md Dry run — nothing written. ``` The result is a small JSON file, `index.md.questions.json`, committed alongside the article: **blog/2025/09/12/docusaurus-go-top/index.md.questions.json** ```json { "version": 1, "model": "task-tiny:latest", "generated": "2026-08-12T13:24:09.634Z", "source": "index.md", "sourceHash": "981d607fb90f06dd35b65cdfa3d549560453a8ea", "questions": [ { "question": "How do I add a 'go to the top' button to my Docusaurus blog posts using a custom React component and theme override?", "anchor": "what-it-looks-like" }, { "question": "What are the steps to implement a 'scroll to top' button in Docusaurus blog posts?", "anchor": "how-it-works" }, { "question": "How can I add a 'go to the top' button to other pages in my Docusaurus site besides blog posts?", "anchor": "you-can-do-this-for-other-pages-for-sure" }, { "question": "What is the purpose of overriding the BlogPostItem page in Docusaurus for adding a 'go to the top' button?", "anchor": "overriding-the-blogpostitem-page" }, { "question": "How do I create and use a custom React component for the 'go to the top' button in Docusaurus blog posts?", "anchor": "how-it-works" }, { "question": "What is the CSS for styling the 'go to the top' button in Docusaurus blog posts?", "anchor": "how-it-works" }, { "question": "How do I inject the custom React component for the 'go to the top' button into my Docusaurus pages?", "anchor": "overriding-the-blogpostitem-page" }, { "question": "What is the purpose of swizzling in Docusaurus and how can it be used to add a 'go to the top' button to blog posts?", "anchor": "how-it-works" } ] } ``` Three things make that output usable rather than merely plausible. **The model is told what "specific" means.** The system prompt bans generic questions outright ("What is Docker?"), demands a mix of short keyword-style queries and full sentences, and forbids two questions that differ only by word order: ```text title="scripts/generate-questions.mjs — system prompt (extract)" - Every question must be SPECIFIC to this article's actual content — never generic ("What is Docker?", "How does Markdown work?"). - Vary the phrasing style: some short keyword-style queries, some full questions. - Each question maps to exactly one heading by index. Index 0 means "the article as a whole / introduction", not tied to a specific heading. - Do not invent facts not supported by the material given. ``` **The answer shape is enforced, not hoped for.** Ollama accepts a JSON schema in `format:`, so the model cannot return prose, a markdown list, or a differently-named field. The script still validates every item afterwards, and fails the whole article if fewer than five survive — a thin entry is worse than no entry. **Headings are resolved to real anchors.** The prompt gives the model a numbered heading list and asks for an index; the script maps that index back to the slug Docusaurus itself will generate, using the same `github-slugger` package Docusaurus uses internally. A wrong index is not fatal — the question falls back to the top of the article rather than being thrown away. Here is the whole generator: **scripts/generate-questions.mjs** ```mjs #!/usr/bin/env node /** * "Ask my blog" question generation — see .todos/0083-ask-my-blog-question-index.md. * * For one article (or, with --all, every published article under blog/), asks a local * Ollama model to write 8-12 search questions a developer would actually type into a search * bar for the problem this article solves, each mapped to the `##`/`###` heading that answers * it. Writes `
.questions.json` alongside the article — same convention as the * existing `.eli5.json` sidecar files (see scripts/lib/eli5-hash.mjs). * * The semantic work happens once, at build time, on the author's machine — not in every * reader's browser. See the TODO's "Ce qu'on ne fait pas, et pourquoi" section for why * client-side embeddings were rejected in favor of this precompute-then-lexical-match design. * * Usage: * node scripts/generate-questions.mjs [--force] [--output ] * node scripts/generate-questions.mjs --all [--force] [--dir blog/] [--limit N] [--dry-run] * node scripts/generate-questions.mjs --locale fr * node scripts/generate-questions.mjs --locale fr --all [--force] [--limit N] [--dry-run] * * With --locale (TODO 0120), the questions are generated FROM THE TRANSLATED ARTICLE, in that * language, and written next to it under `i18n//docusaurus-plugin-content-blog/`. * Generating from the English text and translating the questions would describe wording — and * heading anchors — the reader never sees. Which articles qualify is decided by * `scripts/lib/i18n-eligibility.mjs`, never re-derived here. A localized sidecar whose * translated article changed is regenerated without --force; a fresh one is left alone. * * Requires a local Ollama instance — see OLLAMA_URL below. No API key: this never leaves the * author's machine (or, in the devcontainer, the host it runs on). */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import GithubSlugger from "github-slugger"; import { hashSource } from "./lib/eli5-hash.mjs"; import { localizedBlogPath, questionCandidates } from "./lib/i18n-eligibility.mjs"; import { findPosts, parseFrontMatter, stripCodeFences, toProse, } from "./lib/blog-corpus.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // The devcontainer reaches the host's Ollama through the Docker bridge gateway — same // address this project's other host-side tooling (AnythingLLM) already uses. Override with // OLLAMA_URL when running outside the devcontainer (e.g. `http://localhost:11434`). const OLLAMA_URL = process.env.OLLAMA_URL || "http://172.17.0.1:11434"; // code-quality with thinking, for every locale. Blind bench on Ollama 0.34.3 (2026-09-23), // 16 English outputs: code-quality 4.5/5 at ~36 s/article, think:false 4.0, code-fast 3.4, // task-tiny 1.9 — it mapped every question of a 31-heading article to the introduction. // Only thinking reaches the late headings: the prose below is capped at 4000 chars. const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "code-quality:latest"; // The `code-quality` tag pointed to a different, ~5x slower model before the 2026-09-23 stack // upgrade. Timings recorded earlier would inflate every bulk-run estimate, so they are ignored. const TIMINGS_VALID_SINCE = "2026-09-23"; // Seconds of idle between two articles in a bulk run. A full French corpus is hours of // uninterrupted GPU load, typically left running overnight — the pause costs ~16% of wall time // and buys a lower sustained temperature and quieter fans. `--pause 0` turns it off. const DEFAULT_PAUSE_SECONDS = 30; const MIN_QUESTIONS = 8; const MAX_QUESTIONS = 12; // A response can drop a few items to validation (bad index, empty text) without being // considered malformed. Below this, something is wrong with the prompt/model for this // article and the whole article must fail rather than ship a thin, low-value entry. const MIN_VALID_QUESTIONS = 5; // Output language per locale. The instruction is spelled out in the prompt because a 3B model // otherwise drifts back to English whenever the article quotes English commands or titles. // // `model` stays per locale so one language can diverge again; today both use the default. // French was on code-quality first: on the Docling pilot (2026-09-17), task-tiny shifted the // heading index by one on 5 of 12 questions, while code-quality mapped all 10 correctly. const LANGUAGES = { en: { name: "English", extra: "", model: OLLAMA_MODEL }, fr: { name: "French", model: OLLAMA_MODEL, extra: ` - Write every question in natural French, the way a French-speaking developer would type it (tutoiement or impersonal phrasing, never a word-for-word translation of English). - Keep product names, commands, file names and flags exactly as written (Docker, \`docker compose\`, \`.bashrc\`) — never translate them.`, }, }; const systemPromptFor = (locale) => { const { name, extra } = LANGUAGES[locale]; return SYSTEM_PROMPT.replace("in English.", `in ${name}.`) + extra; }; const SYSTEM_PROMPT = `You are helping build a search index for a technical blog. Given an article's title, description, tags, and section headings, write search questions a developer would actually type into a search bar when they have the problem this article solves — using their own vocabulary, which may differ from the article's wording. Rules: - 8 to 12 questions, in English. - Every question must be SPECIFIC to this article's actual content — never generic ("What is Docker?", "How does Markdown work?"). - Vary the phrasing style: some short keyword-style queries, some full questions. - Each question maps to exactly one heading by index. Index 0 means "the article as a whole / introduction", not tied to a specific heading. - Do not invent facts not supported by the material given. - Every question must be meaningfully different from every other one — no two questions may ask the same thing with swapped words (e.g. "convert CSV to Markdown" and "convert Markdown to CSV" are different; "how to convert CSV to Markdown" and "convert CSV to Markdown" are NOT — pick one).`; const RESPONSE_SCHEMA = { type: "object", properties: { questions: { type: "array", minItems: MIN_QUESTIONS, maxItems: MAX_QUESTIONS, items: { type: "object", properties: { question: { type: "string" }, headingIndex: { type: "integer" }, }, required: ["question", "headingIndex"], }, }, }, required: ["questions"], }; /** * Strips the inline formatting a heading may carry before it becomes an anchor/prompt text. * Resolves each construct to its inner text (bold/italic/code/link label) rather than * dropping it — this must match Docusaurus's own remark-slug algorithm, which keeps a code * span's text as part of the heading it slugs. Deliberately NOT toProse() from blog-corpus.mjs: * that helper is built for prose/search extraction, where blanking out code spans as "not real * prose" is correct — but doing the same here produced anchors Docusaurus never generates * (e.g. "## What `ai-test` Does For You" slugged to "what---does-for-you" instead of * "what-ai-test-does-for-you"), breaking every link built from it. */ function cleanHeadingText(text) { return text .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/<[^>]+>/g, "") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/`([^`]+)`/g, "$1") .trim(); } /** * Collects `##`/`###` headings in document order, resolving each to the anchor slug * Docusaurus would generate for it (github-slugger — same package Docusaurus's own MDX * pipeline uses), honoring an explicit `{#custom-id}` override when present. */ export function extractHeadings(body) { const slugger = new GithubSlugger(); const headings = []; for (const line of stripCodeFences(body)) { // The custom id accepts anything but a brace or a space: the ids pinned onto translated // articles are the slugs github-slugger derives from the English headings, and an emoji // heading yields one that starts with a variation selector (`{#\uFE0F-a-final-word-…}`). // An `[a-zA-Z0-9-_]+` class missed those, the `{#…}` suffix stayed part of the heading text, // and the anchor became the slug of "French heading + English id" — a link to nowhere. const match = line.match(/^(#{2,3})\s+(.*?)\s*(?:\{#([^}\s]+)\})?$/); if (!match) continue; const [, hashes, rawText, customId] = match; const text = cleanHeadingText(rawText); if (!text) continue; const anchor = customId || slugger.slug(text); headings.push({ level: hashes.length, text, anchor }); } return headings; } function buildUserPrompt({ title, description, tags, headings, prose }) { const headingsList = [ "0: (general / introduction — not tied to a specific heading)", ...headings.map((h, i) => `${i + 1}: ${h.text}`), ].join("\n"); // Bounding the prose keeps prompts (and generation time) predictable across the corpus — // title/description/headings already carry most of the signal a 3B model needs. const MAX_PROSE_CHARS = 4000; const boundedProse = prose.length > MAX_PROSE_CHARS ? prose.slice(0, MAX_PROSE_CHARS) + " […]" : prose; return `Title: ${title} Description: ${description} Tags: ${tags.join(", ") || "(none)"} Headings: ${headingsList} Article body (for context only — do not quote it verbatim): ${boundedProse} Write the search questions now.`; } async function callOllama(userPrompt, locale, model) { let res; try { res = await fetch(`${OLLAMA_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, stream: false, format: RESPONSE_SCHEMA, options: { temperature: 0.4 }, messages: [ { role: "system", content: systemPromptFor(locale) }, { role: "user", content: userPrompt }, ], }), }); } catch (err) { throw new Error( `Could not reach Ollama at ${OLLAMA_URL} (is it running? set OLLAMA_URL to override): ${err.message}`, { cause: err }, ); } if (!res.ok) { const body = await res.text(); throw new Error(`Ollama returned HTTP ${res.status}: ${body}`); } const json = await res.json(); return json.message?.content ?? ""; } /** Validates and normalizes the model's raw output into `{ question, anchor }` entries. */ function toValidatedQuestions(raw, headings) { let parsed; try { parsed = JSON.parse(raw); } catch { throw new Error(`Model response is not valid JSON:\n${raw}`); } if (!parsed || !Array.isArray(parsed.questions)) { throw new Error(`Model response is missing a "questions" array:\n${raw}`); } const seen = new Set(); const questions = []; for (const item of parsed.questions) { if (!item || typeof item.question !== "string" || !item.question.trim()) continue; if (!Number.isInteger(item.headingIndex)) continue; const question = item.question.trim(); // Same normalization as questions-index-plugin's normalizeForDedupe(): accents folded // first, or "créer" and "cr er" would be the same key. const dedupeKey = question .normalize("NFD") .replace(/\p{M}/gu, "") .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, " ") .trim(); if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); // A small model miscounting the heading list is a wrong index on an otherwise good, // specific question — worth keeping by falling back to "general" (anchor "") rather than // discarding it outright. Only a genuinely broken shape (missing text, non-integer index) // drops the item entirely, per the "malformed must fail, not silently degrade" rule. const inRange = item.headingIndex >= 1 && item.headingIndex <= headings.length; const anchor = inRange ? headings[item.headingIndex - 1].anchor : ""; questions.push({ question, anchor }); } if (questions.length < MIN_VALID_QUESTIONS) { throw new Error( `Only ${questions.length} valid question(s) survived validation (need >= ${MIN_VALID_QUESTIONS}):\n${raw}`, ); } return questions; } /** * Generates (or skips) the `.questions.json` sidecar for one article. * `articleFile` is the `index.md`/`index.mdx` path, absolute or repo-relative. */ export async function generateQuestions( articleFile, { force = false, outputPath = null, locale = "en" } = {}, ) { if (!LANGUAGES[locale]) { throw new Error( `Unsupported locale "${locale}" (known: ${Object.keys(LANGUAGES).join(", ")}).`, ); } const absSource = locale === "en" ? path.resolve(articleFile) : resolveTranslated(articleFile, locale); if (!fs.existsSync(absSource)) { throw new Error(`Article not found: ${absSource}`); } const destPath = outputPath || absSource + ".questions.json"; // An article excluded during review (scripts/questions-review.mjs, `x`) must never get // questions again — not even with --force, which is exactly the run that would otherwise // undo the decision. Only `questions review ` + `i` lifts it. if (fs.existsSync(destPath)) { let existing; try { existing = JSON.parse(fs.readFileSync(destPath, "utf-8")); } catch { existing = null; } if (existing?.excluded === true) { return { status: "excluded", path: destPath }; } // A localized sidecar follows its translation: stale means regenerate, even without // --force — that is the whole point of the hash. The English corpus keeps its historical // "exists = skip" behavior (its staleness is reported by questions:check instead). const fresh = locale === "en" || existing?.sourceHash === hashSource(fs.readFileSync(absSource, "utf-8")); if (!force && fresh) { return { status: "skipped", path: destPath }; } } const raw = fs.readFileSync(absSource, "utf-8"); const { data, body } = parseFrontMatter(raw); if (!data.title) { throw new Error(`${path.relative(projectRoot, absSource)} has no frontmatter title.`); } const headings = extractHeadings(body); const prose = toProse(body); const tags = Array.isArray(data.tags) ? data.tags : []; const userPrompt = buildUserPrompt({ title: data.title, description: data.description || "", tags, headings, prose, }); const model = LANGUAGES[locale].model; const startedAt = Date.now(); const modelRaw = await callOllama(userPrompt, locale, model); const questions = toValidatedQuestions(modelRaw, headings); const durationMs = Date.now() - startedAt; const result = { version: 1, model, generated: new Date().toISOString(), // Read back by pastDurations() to estimate how long the next bulk run will take. durationMs, source: path.basename(absSource), ...(locale === "en" ? {} : { locale }), sourceHash: hashSource(raw), questions, }; fs.writeFileSync(destPath, JSON.stringify(result, null, 2) + "\n"); return { status: "generated", path: destPath, count: questions.length, durationMs }; } /** * The translated article for a `--locale` run. Accepts either the English source * (`blog/…/index.md`) or the translated file itself, so the path the author has at hand works. */ function resolveTranslated(articleFile, locale) { const abs = path.resolve(articleFile); const blogRoot = path.join(projectRoot, "blog") + path.sep; if (!abs.startsWith(blogRoot)) return abs; const translated = localizedBlogPath(projectRoot, abs, locale); if (!fs.existsSync(translated)) { throw new Error( `${path.relative(projectRoot, abs)} is not translated into ${locale} — translate it first ` + `(\`translate ${path.relative(projectRoot, path.dirname(abs))}\`).`, ); } // Same condition 2 as the eligibility module: no English questions, no localized ones. try { const english = JSON.parse(fs.readFileSync(`${abs}.questions.json`, "utf-8")); if (english.excluded === true) { throw new Error( `${path.relative(projectRoot, abs)} is excluded from "Ask my blog".`, ); } } catch (err) { if (err.code !== "ENOENT" && !(err instanceof SyntaxError)) throw err; } return translated; } // ── Timing ─────────────────────────────────────────────────────────────────── /** * Past generation times for `model`, read back from the `durationMs` each sidecar records. * Sidecars written before that field existed, or before TIMINGS_VALID_SINCE, simply don't count. */ function pastDurations(model) { const durations = []; for (const root of ["blog", "i18n"]) { const dir = path.join(projectRoot, root); if (!fs.existsSync(dir)) continue; for (const rel of fs.readdirSync(dir, { recursive: true })) { if (!rel.endsWith(".questions.json")) continue; try { const sidecar = JSON.parse(fs.readFileSync(path.join(dir, rel), "utf-8")); if ( sidecar.model === model && Number.isFinite(sidecar.durationMs) && sidecar.generated >= TIMINGS_VALID_SINCE ) { durations.push(sidecar.durationMs); } } catch { /* an unreadable sidecar is reported by questions:check, not here */ } } } return durations; } const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length; function formatDuration(ms) { const seconds = Math.round(ms / 1000); if (seconds < 60) return `${seconds} s`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} min`; return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, "0")} min`; } // ── Bulk mode ──────────────────────────────────────────────────────────────── /** * `--pause` between two articles. It spends no less energy on the run — the same work is done * either way — but it lowers the sustained GPU temperature and fan noise of an unattended * multi-hour batch, which is the point: one process to launch, not a tranche to restart by hand. */ const pauseBetweenArticles = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); /** ` 3/58` — padded so the counter column stays aligned from the first article to the last. */ const counterOf = (index, total) => `${String(index + 1).padStart(String(total).length)}/${total}`; /** `--locale` bulk run: the eligibility module decides, this only reports and generates. */ async function runLocalizedBulk({ locale, force, limit, dryRun, pause }) { const { eligible, skipped } = questionCandidates(projectRoot, locale); // --force re-runs the fresh ones too, never the excluded or the untranslated. const fresh = skipped .filter((s) => s.reason === "localized sidecar already fresh") .map((s) => s.file); const all = force ? [...eligible, ...fresh].sort() : eligible; const targets = typeof limit === "number" ? all.slice(0, limit) : all; console.log( `🔍 ${targets.length} translated article(s) to process for "${locale}" ` + `(${fresh.length} already fresh, ${skipped.length - fresh.length} not eligible).`, ); // A full run on code-quality takes hours — say so before it starts, not after. const model = LANGUAGES[locale]?.model; const history = model ? pastDurations(model) : []; if (model && targets.length > 0) { console.log( history.length > 0 ? `⏱ Estimated time: ~${formatDuration( targets.length * mean(history) + Math.max(targets.length - 1, 0) * pause * 1000, )} ` + `(${targets.length} × ${Math.round(mean(history) / 1000)} s average per article ` + `for ${model}, measured on ${history.length} article(s)).` : `⏱ No timing history yet for ${model} — the next run will show an estimate.`, ); if (pause) { console.log(`⏸ Pausing ${pause}s between articles to keep the GPU cooler.`); } } let generated = 0, errors = 0; for (const [index, file] of targets.entries()) { const rel = path.relative(projectRoot, file); const counter = counterOf(index, targets.length); if (dryRun) { console.log(` [GENERATE] ${counter} ${rel}`); continue; } process.stdout.write(` 📄 ${counter} ${rel} ... `); try { const result = await generateQuestions(file, { force, locale }); console.log( result.status === "generated" ? `✅ ${result.count} questions` : `⏭ ${result.status}`, ); if (result.status === "generated") generated++; } catch (err) { console.log(`❌ ${err.message}`); errors++; } if (pause && index < targets.length - 1) await pauseBetweenArticles(pause); } if (dryRun) console.log("\nDry run — nothing written."); else console.log(`\nGenerated: ${generated} Errors: ${errors}`); if (errors > 0) process.exitCode = 1; } async function runBulk({ force, dir, limit, dryRun, pause }) { // Drafts (blog/ with draft: true, or anything under .unpublished/) aren't public yet — the // aggregation plugin already excludes them from the shipped index, but generating sidecars // for them here would still burn Ollama time and commit sidecar files for unpublished // content. Mirrors the same draft check questions-index-plugin applies at aggregation time. const isDraft = (file) => { const { data } = parseFrontMatter(fs.readFileSync(file, "utf-8")); return data.draft === true || data.draft === "true"; }; const posts = findPosts(dir) .filter((file) => !isDraft(file)) .sort(); const targets = typeof limit === "number" ? posts.slice(0, limit) : posts; console.log( `🔍 ${targets.length} article(s) under ${path.relative(projectRoot, dir)}${ typeof limit === "number" ? ` (--limit ${limit})` : "" }.`, ); if (pause && !dryRun) { console.log(`⏸ Pausing ${pause}s between articles to keep the GPU cooler.`); } if (dryRun) { for (const [index, file] of targets.entries()) { const rel = path.relative(projectRoot, file); const sidecarPath = file + ".questions.json"; const exists = fs.existsSync(sidecarPath); let label = exists && !force ? "SKIP" : "GENERATE"; if (exists) { try { if (JSON.parse(fs.readFileSync(sidecarPath, "utf-8")).excluded === true) { label = "EXCLUDED"; } } catch { /* an unreadable sidecar is reported by questions:check, not here */ } } console.log(` [${label}] ${counterOf(index, targets.length)} ${rel}`); } console.log("\nDry run — nothing written."); return; } let generated = 0, skipped = 0, excluded = 0, errors = 0; for (const [index, file] of targets.entries()) { const rel = path.relative(projectRoot, file); process.stdout.write(` 📄 ${counterOf(index, targets.length)} ${rel} ... `); // Only a real generation heated anything up — a skipped article never reached Ollama. let calledOllama = false; try { const result = await generateQuestions(file, { force }); if (result.status === "excluded") { console.log("🚫 excluded"); excluded++; } else if (result.status === "skipped") { console.log("⏭ skipped"); skipped++; } else { console.log(`✅ ${result.count} questions`); generated++; calledOllama = true; } } catch (err) { console.log(`❌ ${err.message}`); errors++; } if (pause && calledOllama && index < targets.length - 1) { await pauseBetweenArticles(pause); } } console.log(`\n──────────────────────────────────────`); console.log( `Generated: ${generated} Skipped: ${skipped} Excluded: ${excluded} Errors: ${errors}`, ); if (errors > 0) process.exitCode = 1; } // ── CLI ────────────────────────────────────────────────────────────────────── const { fileURLToPath: _fileURLToPath } = await import("url"); const _isMain = process.argv[1] === _fileURLToPath(import.meta.url); if (_isMain) { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log(` Usage: node scripts/generate-questions.mjs [options] node scripts/generate-questions.mjs --all [options] Options: --force Regenerate even if
.questions.json already exists --output (single-file mode) write to a custom path --all Process every published article under --dir --dir Directory to scan in --all mode (default: blog/) --limit (--all mode) only process the first n articles — useful for a hand-reviewed pilot batch before a full corpus run --dry-run (--all mode) show what would be generated without calling Ollama --pause (--all mode) wait between two articles (default: ${DEFAULT_PAUSE_SECONDS}). Same total work, but a lower sustained GPU temperature on an unattended multi-hour batch. --pause 0 disables it --locale Generate from the translated article, in that language (e.g. fr). With --all, only the eligible translated articles are processed. --help, -h Show this help Environment: OLLAMA_URL Ollama endpoint (default: http://172.17.0.1:11434 — the devcontainer's bridge to the host) OLLAMA_MODEL Model name (default: code-quality:latest) `); process.exit(0); } const force = args.includes("--force"); const localeIdx = args.indexOf("--locale"); const locale = localeIdx !== -1 ? args[localeIdx + 1] : "en"; if (localeIdx !== -1 && !LANGUAGES[locale]) { console.error(`Error: unsupported --locale "${locale}".`); process.exit(1); } if (args.includes("--all")) { const dirIdx = args.indexOf("--dir"); const dir = dirIdx !== -1 ? path.resolve(projectRoot, args[dirIdx + 1]) : path.resolve(projectRoot, "blog"); const limitIdx = args.indexOf("--limit"); const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : undefined; const dryRun = args.includes("--dry-run"); const pauseIdx = args.indexOf("--pause"); const pause = pauseIdx !== -1 ? Number(args[pauseIdx + 1]) : DEFAULT_PAUSE_SECONDS; if (pauseIdx !== -1 && (!Number.isFinite(pause) || pause < 0)) { console.error(`--pause needs a number of seconds, got "${args[pauseIdx + 1]}".`); process.exit(1); } if (locale !== "en") { await runLocalizedBulk({ locale, force, limit, dryRun, pause }); } else if (!fs.existsSync(dir)) { console.error(`Error: directory not found: ${dir}`); process.exit(1); } else { await runBulk({ force, dir, limit, dryRun, pause }); } } else { const outputIdx = args.indexOf("--output"); const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null; const articleFile = args.find( (a, i) => !a.startsWith("--") && !(outputIdx !== -1 && i === outputIdx + 1) && !(localeIdx !== -1 && i === localeIdx + 1), ); if (!articleFile) { console.error("Error: no article file specified. Run with --help for usage."); process.exit(1); } try { const result = await generateQuestions(articleFile, { force, outputPath, locale }); if (result.status === "excluded") { console.log(`🚫 Excluded from "Ask my blog": ${path.basename(result.path)}`); console.log( ` Lift it with: node scripts/questions-review.mjs ${articleFile} (then press i)`, ); } else if (result.status === "skipped") { console.log(`⏭ Skipped (already exists): ${path.basename(result.path)}`); console.log(` Use --force to regenerate.`); } else { console.log(`✅ Written: ${result.path}\n ${result.count} questions.`); } } catch (err) { console.error("❌ Error:", err.message); process.exit(1); } } } ``` > **Pick a small model on purpose:** > > This runs on `task-tiny`, a 3B instruct model. Bigger local models produced no better questions in side-by-side comparison and ran roughly ten times slower. Writing search questions from a title, a description and a heading list is an *extraction* task, not a reasoning one — and 259 articles at 11 seconds each is a coffee break, while 259 at two minutes each is an afternoon. ## Serving 2,160 Questions Three Different Ways A Docusaurus plugin aggregates all 259 sidecars into one index. The interesting part is that it does **not** ship that index once — it ships it three times, in three different shapes, because it has three consumers with incompatible needs: | Consumer | What it needs | How it gets it | | -------------------- | ----------------------------------------------------------------------- | ----------------------------------------------- | | The `/faq` hub | 40 topic names and counts, grouped by [main tag](/blog/docusaurus-tags) | `setGlobalData` — a few hundred bytes | | `/faq/` pages | One topic's questions, crawlable | `addRoute` + `createData`, code-split per route | | The search box | The whole corpus | A plain static JSON file, fetched on demand | That third row is the one that matters. The full corpus is **468 KB** (63 KB gzipped), and the search box lives inside a component mounted on *every* page of the site. Exposing it through `setGlobalData` would have bundled all 468 KB into every page's JavaScript, whether or not anyone ever typed a character. So the plugin writes it straight to disk as a static asset instead, and the client `fetch`es it the first time someone actually opens the search box: **plugins/questions-index-plugin/index.cjs** ```cjs /** * @fileoverview Docusaurus Plugin: questions-index-plugin * * Purpose (TODO 0083 — "Ask my blog"): * Aggregates every article's `
.questions.json` sidecar (see * scripts/generate-questions.mjs) into one corpus-wide, build-time question index — roughly * 8-12 "how would a developer search for this" questions per article, each pointing at * (article permalink, heading anchor). * * The corpus is heavy at scale (2000+ questions once the whole blog is covered) and has three * different consumers with three different performance needs, so it's shaped three ways * rather than shipped once as one blob: * * 1. `themes` (tiny — a few dozen rows) via `setGlobalData`, for the `/faq` hub's * table-of-contents. Cheap enough to embed globally like any other small plugin data. * 2. One route per theme (`/faq/`, via `addRoute` + `createData`), each carrying only * that theme's slice. Docusaurus code-splits per route automatically, so this is * server-rendered, crawlable HTML without any one page paying for the whole corpus. * 3. The full corpus as a plain static JSON asset (`/questions-index.json`, written straight * to disk and served via `staticDirectories`, see docusaurus.config.js), for * `AskMyBlog`/`CommandPalette`'s cross-theme search. Deliberately NOT exposed via * `setGlobalData`: that component tree is mounted on every page (`src/theme/Layout`), so a * `usePluginData` reference — even an unused one — would have gotten the whole ~470 KB * corpus bundled into every page's JS. A plain `fetch()` (same bridge pattern * `CommandPalette/utils.ts` already uses for Pagefind) keeps that cost opt-in: only a * reader who actually opens the search box pays for it, once, cached after that. * * An article with no `.questions.json` yet (not generated) simply contributes nothing — the * index degrades gracefully rather than erroring, so this plugin can ship before the corpus * is fully processed (mirrors the "activation progressive" idea in TODO 0084). * * Locales (TODO 0120): under a non-default locale the corpus is read from * `i18n//docusaurus-plugin-content-blog/` — the translated articles and the question * sidecars generated from them (`yarn questions --locale fr`). There is deliberately NO fallback * to the English sidecars: a French reader types a French query, and an English index cannot * answer it. An untranslated article therefore contributes nothing under `/fr/`. */ const fs = require("fs"); const path = require("path"); const { normalizeUrl } = require("@docusaurus/utils"); const frontMatter = require("front-matter"); const yaml = require("js-yaml"); const BLOG_DIR = "blog"; const I18N_BLOG_DIR = "docusaurus-plugin-content-blog"; const GENERATED_DIR_NAME = "questions-index-plugin"; // Public path (under the site's baseUrl) the client fetches for the full corpus — kept in // sync with the `staticDirectories` entry in docusaurus.config.js pointing at this plugin's // generated static folder. const SEARCH_INDEX_FILENAME = "questions-index.json"; // Mirrors plugins/markdown-export-plugin/index.cjs's findPosts()/permalinkFor() — duplicated // rather than imported from scripts/lib/blog-corpus.mjs, which is ESM and cannot be loaded by // a plain CJS require() here (same rationale as that file's own comment on the topic). 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; } // Deliberately NOT prefixed with the locale's baseUrl: every consumer navigates through // `` (or `withBaseUrl` in the command palette), which adds `/fr/` itself. Prefixing // here would produce `/fr/fr/blog/…`. A translation copies `slug` byte for byte, so the same // function serves both locales. See .claude/rules/i18n-locale-safety.md. function permalinkFor(attributes, dir) { if (attributes.slug) { return attributes.slug.startsWith("/") ? attributes.slug : `/blog/${attributes.slug.replace(/^\//, "")}`; } return `/blog/${dir}/`; } // Accents are folded before the ASCII filter: without it, "créer" became "cr er" and two // different French questions could collapse into the same key. function normalizeForDedupe(question) { return question .normalize("NFD") .replace(/\p{M}/gu, "") .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, " ") .trim(); } /** * Drops any question whose normalized text collides with another article's — see the TODO's * Risk section ("si 40 articles génèrent 'How do I install Docker?', l'index est cassé"). * Every colliding instance is dropped, including the first: a question that isn't specific to * exactly one article failed the generation prompt's own "specific to this article" rule, so * keeping one copy would still ship a too-generic entry. */ function dedupeAcrossCorpus(entries) { const counts = new Map(); for (const entry of entries) { const key = normalizeForDedupe(entry.question); counts.set(key, (counts.get(key) || 0) + 1); } const kept = entries.filter( (entry) => counts.get(normalizeForDedupe(entry.question)) === 1, ); return { kept, droppedCount: entries.length - kept.length }; } /** * The directory the corpus is read from: `blog/` on the default locale, the translated tree * otherwise. The sidecars live next to the article they were generated from, so the same walk * finds both. */ function corpusDirFor(siteDir, i18n) { return i18n.currentLocale === i18n.defaultLocale ? path.join(siteDir, BLOG_DIR) : path.join(siteDir, "i18n", i18n.currentLocale, I18N_BLOG_DIR); } function collectEntries(blogDir, siteDir) { if (!fs.existsSync(blogDir)) return { entries: [], articlesWithQuestions: 0 }; const entries = []; let articlesWithQuestions = 0; for (const file of findPosts(blogDir)) { const raw = fs.readFileSync(file, "utf-8"); const { attributes } = frontMatter(raw); if (!attributes.title || attributes.draft === true || attributes.draft === "true") continue; const sidecarPath = `${file}.questions.json`; if (!fs.existsSync(sidecarPath)) continue; let sidecar; try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, "utf-8")); } catch { console.warn( `⚠ questions-index-plugin: unreadable sidecar, skipped — ${path.relative(siteDir, sidecarPath)}`, ); continue; } if (!Array.isArray(sidecar.questions) || sidecar.questions.length === 0) continue; const dir = path.relative(blogDir, path.dirname(file)).split(path.sep).join("/"); const permalink = permalinkFor(attributes, dir); articlesWithQuestions += 1; for (const q of sidecar.questions) { if (!q || typeof q.question !== "string" || !q.question.trim()) continue; entries.push({ question: q.question.trim(), anchor: typeof q.anchor === "string" ? q.anchor : "", permalink, title: attributes.title, mainTag: attributes.mainTag || null, }); } } const { kept, droppedCount } = dedupeAcrossCorpus(entries); if (droppedCount > 0) { console.log( `questions-index-plugin: dropped ${droppedCount} cross-article duplicate question(s).`, ); } return { entries: kept, articlesWithQuestions }; } function readTags(tagsYamlPath) { try { return yaml.load(fs.readFileSync(tagsYamlPath, "utf-8")) ?? {}; } catch { return {}; } } /** * Tag labels for the theme headings. The localized `tags.yml` is the one Docusaurus already * renders `/fr/blog/tags/` from (same source as `src/components/Blog/utils/tagsI18n.ts`); the * English file fills any key it lacks. */ function loadTagLabels(siteDir, blogDir) { const english = readTags(path.join(siteDir, BLOG_DIR, "tags.yml")); const localized = readTags(path.join(blogDir, "tags.yml")); return { ...english, ...localized }; } /** Groups entries by mainTag, sorted alphabetically by display label — same grouping the * `/faq` hub and each `/faq/` page render. */ // The one theme label that does not come from `tags.yml` (articles with no `mainTag`). const OTHER_LABEL = { en: "Other", fr: "Autres" }; function buildThemes(entries, tagLabels, locale) { const groups = new Map(); for (const entry of entries) { const key = entry.mainTag || "other"; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(entry); } return [...groups.entries()] .map(([key, items]) => ({ key, label: key === "other" ? (OTHER_LABEL[locale] ?? OTHER_LABEL.en) : (tagLabels[key]?.label ?? key), items: [...items].sort((a, b) => a.question.localeCompare(b.question)), })) .sort((a, b) => a.label.localeCompare(b.label)); } function buildIndex(siteDir, i18n) { const blogDir = corpusDirFor(siteDir, i18n); const { entries, articlesWithQuestions } = collectEntries(blogDir, siteDir); const tagLabels = loadTagLabels(siteDir, blogDir); const themes = buildThemes(entries, tagLabels, i18n.currentLocale); return { // No build timestamp: this index ships in the client bundle and in // build/questions-index.json, and a wall-clock value would change // main.js's hash on every build (see plugins/blog-graph-plugin). entries, themes, meta: { articleCount: articlesWithQuestions, questionCount: entries.length, }, }; } module.exports = function questionsIndexPlugin(context) { const { siteDir, generatedFilesDir } = context; return { name: "questions-index-plugin", async loadContent() { return buildIndex(siteDir, context.i18n); }, async contentLoaded({ content, actions }) { const { entries, themes, meta } = content; // 3. Full corpus, as a plain static asset — see the file header for why this is // deliberately NOT setGlobalData(). Written directly to disk (not via createData, which // produces a webpack-bundled module) so it's a plain HTTP resource the client fetches // on demand; served via the `staticDirectories` entry in docusaurus.config.js. const staticDir = path.join(generatedFilesDir, GENERATED_DIR_NAME, "static"); fs.mkdirSync(staticDir, { recursive: true }); fs.writeFileSync( path.join(staticDir, SEARCH_INDEX_FILENAME), JSON.stringify(entries), ); // 2. One route per theme, each carrying only its own slice — code-split per route by // Docusaurus, so no page other than that theme's own pays for the others' questions. for (const theme of themes) { const dataPath = await actions.createData( `${theme.key}.json`, JSON.stringify(theme), ); await actions.addRoute({ // Through `baseUrl` — see docusaurus-plugin-tag-route. Hardcoding the path leaves // the route unreachable under a non-default locale. path: normalizeUrl([context.baseUrl, "faq", theme.key]), component: "@site/src/components/FaqThemePage", modules: { theme: dataPath }, exact: true, }); } // 1. Small summary for the `/faq` hub's table of contents — cheap enough to embed // globally like any other small plugin data. actions.setGlobalData({ meta, themes: themes.map((theme) => ({ key: theme.key, label: theme.label, permalink: `/faq/${theme.key}`, count: theme.items.length, })), }); }, getPathsToWatch() { // Rebuild the index in `yarn start` whenever an article or its question sidecar changes. return [ path.join(siteDir, "blog/**/index.{md,mdx}"), path.join(siteDir, "blog/**/*.questions.json"), path.join(siteDir, "blog/tags.yml"), path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/**/index.{md,mdx}`), path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/**/*.questions.json`), path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/tags.yml`), ]; }, }; }; ``` The fetch is cached at module scope, so the palette and the `/faq` page share one download — and a failed fetch clears the cache rather than poisoning it, so a reader who searched before the dev server finished building can simply try again: **src/components/AskMyBlog/questionsIndex.ts** ```typescript /** * Lazy-loads the full "Ask my blog" question corpus (see plugins/questions-index-plugin) as a * plain HTTP fetch against a static JSON asset, instead of `usePluginData` — that corpus is * ~470 KB uncompressed / ~60 KB gzip once the whole blog is covered, and `AskMyBlog` / * `CommandPalette`'s `?` mode are the only two places that need the *full*, cross-theme * corpus. `CommandPalette` in particular is mounted on every page (`src/theme/Layout`), so a * `usePluginData` reference there would have shipped that whole corpus in every page's JS — * see the plugin's own header comment for the measured impact. * * Fetched once and cached at module scope (shared between `AskMyBlog` and `CommandPalette`, * whichever opens search first) — same "fetch a static build-time asset" bridge * `CommandPalette/utils.ts` already uses for Pagefind. */ import type { QuestionEntry } from "./utils"; let inFlight: Promise | null = null; /** `questionsIndexUrl` should be the baseUrl-aware absolute path (see `useBaseUrl`/ * `useBaseUrlUtils` — this module can't call that hook itself, it isn't a component). */ export function loadQuestionsIndex(questionsIndexUrl: string): Promise { if (!inFlight) { inFlight = fetch(questionsIndexUrl) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; }) .catch((err) => { // Let the next caller retry instead of caching a permanent failure — e.g. a reader // opening search before the dev server has finished its first build. inFlight = null; throw err; }); } return inFlight; } ``` And the search box itself: **Project: src/components/AskMyBlog** **src/components/AskMyBlog/index.tsx** ```typescript /** * AskMyBlog — a search-as-you-type box over the build-time "ask my blog" question index * (see .todos/0083-ask-my-blog-question-index.md and plugins/questions-index-plugin). * * Lazy-fetches the full `{ question, anchor, permalink, title, mainTag }` corpus on mount * (./questionsIndex.js — a plain static JSON asset, not `usePluginData`; see that module's * comment for why) and ranks it against the reader's query with plain BM25 (./utils.js), * entirely client-side: the semantic work already happened once at build time, when Ollama * phrased each question in a developer's own search vocabulary. */ import { useEffect, useMemo, useState, type JSX } from "react"; import Link from "@docusaurus/Link"; import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import { buildSearchIndex, search, type QuestionEntry } from "./utils"; import { loadQuestionsIndex } from "./questionsIndex"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { placeholder?: string; maxResults?: number; showLabel?: boolean; } export default function AskMyBlog({ placeholder, maxResults = 8, showLabel = true, }: Props): JSX.Element { const { withBaseUrl } = useBaseUrlUtils(); // null = not fetched yet, [] = loaded (possibly empty), only ever read after loading. const [questions, setQuestions] = useState(null); const [loadFailed, setLoadFailed] = useState(false); const [query, setQuery] = useState(""); useEffect(() => { let cancelled = false; loadQuestionsIndex(withBaseUrl("/questions-index.json")) .then((data) => { if (!cancelled) setQuestions(data); }) .catch(() => { if (!cancelled) setLoadFailed(true); }); return () => { cancelled = true; }; }, [withBaseUrl]); const searchIndex = useMemo(() => buildSearchIndex(questions ?? []), [questions]); const trimmed = query.trim(); const results = useMemo( () => (trimmed && questions ? search(searchIndex, trimmed, maxResults) : []), [searchIndex, trimmed, maxResults, questions], ); const status = !trimmed ? null : !questions && !loadFailed ? translate({ id: "askMyBlog.loading", message: "Loading…" }) : loadFailed ? translate({ id: "askMyBlog.unavailable", message: "Search is unavailable right now.", }) : results.length === 1 ? translate({ id: "askMyBlog.resultCount.one", message: "1 result" }) : translate( { id: "askMyBlog.resultCount.other", message: "{count} results" }, { count: results.length }, ); // Resolved inside the component, not as a parameter default: a default value is evaluated at // module scope, where translate() would freeze the locale of the first bundle load. const resolvedPlaceholder = placeholder ?? translate({ id: "blog.askMyBlog.defaultPlaceholder", message: 'Ask a question, e.g. "how do I reduce my image size?"', }); return (
setQuery(event.target.value)} placeholder={resolvedPlaceholder} autoComplete="off" aria-describedby="ask-my-blog-status" />
{status}
{results.length > 0 && (
    {results.map((entry, index) => (
  • {entry.question} {entry.title}
  • ))}
)} {trimmed && questions && !loadFailed && results.length === 0 && (

{/* Deliberately small: this state comes and goes on every keystroke, so a large illustration would flash while the reader types. */} {translate({ No matching article yet — try different words.

)}
); } ``` **src/components/AskMyBlog/utils.ts** ```typescript /** * Pure BM25 lexical scoring over the build-time question index — see the plugin * (plugins/questions-index-plugin) for where `questions` comes from and ../../../.todos * (0083) for why this is plain lexical matching rather than embeddings: the semantic bridge * between a reader's vocabulary and an article's wording was already built once, at build * time, by the model that phrased each question — nothing here needs to "understand" the * query, only find the closest wording among the ~2500 precomputed ones. */ // Standard Okapi BM25 constants — term-frequency saturation and length-normalization strength. const K1 = 1.5; const B = 0.75; export interface QuestionEntry { question: string; anchor?: string; permalink: string; title: string; mainTag?: string; } export interface SearchIndex { questions: QuestionEntry[]; docs: string[][]; docFrequency: Map; avgDocLength: number; docCount: number; } /** Lowercase word tokens — good enough for short question strings; hyphenated terms like * "php-cs-fixer" split into individual words, which still matches a query typed either way. * CamelCase/compound product names (e.g. "CaesiumCLT") get a boundary inserted before * lowercasing, so a query for just "caesium" still matches the "caesium" part of the token — * without this, "caesiumclt" is one indivisible token that a partial name never matches. * The *unsplit* form is kept too (union, not replace): "WordPress" would otherwise only * yield "word" + "press", and a reader typing "wordpress" as one word would match neither. * * Letters are matched as Unicode (`\p{L}`), then folded to their unaccented form: an ASCII-only * class split the French corpus mid-word ("déployer" became "d" + "ployer"), and folding lets a * reader typing "deployer" find "déployer". Applied to both the index and the query, since both * go through this function. A no-op on the English corpus. See TODO 0120. */ export function tokenize(text: string): string[] { const folded = text.normalize("NFD").replace(/\p{M}/gu, ""); const withBoundaries = folded.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2"); const split = withBoundaries.toLowerCase().match(/[\p{L}\p{N}]+/gu) || []; const whole = folded.toLowerCase().match(/[\p{L}\p{N}]+/gu) || []; return [...new Set([...split, ...whole])]; } /** * Precomputes everything BM25 needs to score `questions` against future queries: per-document * tokens, document length, and per-term document frequency. Call once when the index loads, * not per keystroke — scoring itself (in `search`) is the only per-keystroke work. */ export function buildSearchIndex(questions: QuestionEntry[]): SearchIndex { const docs = questions.map((entry) => tokenize(entry.question)); const docFrequency = new Map(); for (const tokens of docs) { for (const term of new Set(tokens)) { docFrequency.set(term, (docFrequency.get(term) || 0) + 1); } } const avgDocLength = docs.reduce((sum, tokens) => sum + tokens.length, 0) / (docs.length || 1); return { questions, docs, docFrequency, avgDocLength, docCount: docs.length }; } function idf(docFrequency: Map, docCount: number, term: string) { const n = docFrequency.get(term) || 0; return Math.log((docCount - n + 0.5) / (n + 0.5) + 1); } /** Expands one query token to the indexed terms it should match: itself, if it's already an * indexed term, or — for a still-being-typed prefix like "docus" — every indexed term it's a * prefix of (e.g. "docusaurus"). Below 3 characters a prefix matches too much of the vocabulary * to be useful, so short unindexed tokens are dropped instead of expanded. */ function expandTerm(docFrequency: Map, term: string): string[] { if (docFrequency.has(term)) return [term]; if (term.length < 3) return []; const matches = []; for (const key of docFrequency.keys()) { if (key.startsWith(term)) matches.push(key); } return matches; } /** Scores and ranks `index` against `query`, returning the top `limit` matches with score > 0. */ export function search(index: SearchIndex, query: string, limit = 8): QuestionEntry[] { const queryTerms = tokenize(query); if (queryTerms.length === 0) return []; const { questions, docs, docFrequency, avgDocLength, docCount } = index; // Expand once per query, not per document — the doc loop below only ever scores against // real indexed terms. const expandedTerms = queryTerms.flatMap((term) => expandTerm(docFrequency, term)); if (expandedTerms.length === 0) return []; const scored: { entry: QuestionEntry; score: number }[] = []; for (let i = 0; i < docs.length; i += 1) { const tokens = docs[i]; if (tokens.length === 0) continue; let score = 0; for (const term of expandedTerms) { const freq = tokens.filter((t) => t === term).length; if (freq === 0) continue; const numerator = freq * (K1 + 1); const denominator = freq + K1 * (1 - B + (B * tokens.length) / avgDocLength); score += idf(docFrequency, docCount, term) * (numerator / denominator); } if (score > 0) scored.push({ entry: questions[i], score }); } scored.sort((a, b) => b.score - a.score); return scored.slice(0, limit).map((s) => s.entry); } ``` **src/components/AskMyBlog/styles.module.css** ```css .container { margin: 1.5rem 0; } /* Was a hand-rolled eyebrow (0.85rem / 600 / 0.04em). */ .label { composes: mk-eyebrow from global; display: block; margin-bottom: 0.4rem; } .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .input { width: 100%; padding: 0.75rem 1rem; font-family: inherit; font-size: 1.05rem; color: var(--mk-ink); background: var(--mk-surface); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-md); transition: border-color 0.15s ease, box-shadow 0.15s ease; } .input::placeholder { color: var(--mk-n-500); } .input:focus { outline: none; border-color: var(--mk-primary); box-shadow: 0 0 0 3px var(--mk-key-bg); } .status { margin-top: 0.4rem; font-size: 0.85rem; color: var(--mk-ink-soft); min-height: 1.2em; } .results { list-style: none; margin: 0.5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; } .resultItem { display: flex; flex-direction: column; padding: 0.6rem 0.9rem; background: var(--mk-surface); border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); } .resultLink { font-weight: 600; } .resultSource { margin-top: 0.15rem; font-size: 0.8rem; color: var(--mk-ink-soft); } /* The italic is dropped: it is a status line, and italics on a warm neutral at this size read as an error rather than as "nothing found". */ .empty { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.75rem; color: var(--mk-ink-soft); } /* The thumbnail ships on an opaque cream background, so round it off and let it sit on its own tint rather than cutting a pale square out of a dark page. */ .emptyImage { flex: 0 0 auto; width: 56px; height: 56px; border-radius: 50%; object-fit: cover; } ``` ## Under the Hood (skip this if you just want the questions) ### Querying the index from the command line The JSON file is a plain array, so you can probe it outside the browser with a handful of lines. This is useful for debugging the index before shipping, or for checking whether a particular phrasing matches: ```bash $ npx tsx --eval " import { buildSearchIndex, search } from './src/components/AskMyBlog/utils.ts'; import fs from 'fs'; const rel = 'questions-index-plugin/static/questions-index.json'; const p = ['.docusaurus', '.docusaurus-dev'].map(d => d + '/' + rel).find(fs.existsSync); if (p === undefined) throw new Error('Run yarn build or start the dev server first'); const corpus = JSON.parse(fs.readFileSync(p, 'utf8')); const index = buildSearchIndex(corpus); for (const r of search(index, process.argv[1], 4)) { console.log(r.question); console.log(' ' + r.title + ' -> ' + r.permalink + (r.anchor ? '#' + r.anchor : '')); }" "run docusaurus in docker" How do I run my Docusaurus site using Docker? Running Docusaurus with Docker -> /blog/docusaurus-docker#run-docusaurus What commands are used to build and run the production Docker image for Docusaurus? Running Docusaurus using Docker -> /blog/running-docusaurus-with-docker#build-and-run-the-production-image How do I run Quarto Markdown in Docker? Running Quarto Markdown in Docker -> /blog/docker-quarto How do I run Firefox in a Docker container? Docker - Run Graphical User Interfaces - Firefox, Chrome & GIMP -> /blog/docker-run-linux-gui#creating-our-own-chrome-docker-image ``` ### BM25 in forty lines The ranking is Okapi BM25 — term frequency with saturation, plus length normalization, weighted by inverse document frequency. It is what Lucene and Elasticsearch use, and it fits in one small file because each "document" here is a single question of a dozen words. Two adaptations were needed for that unusually short document length. **Prefix matching, expanded once per query.** A reader typing `docus` has not finished the word yet, so an unknown token of three characters or more is expanded to every indexed term it prefixes. Below three characters a prefix matches too much of the vocabulary to mean anything, so short unknown tokens are dropped instead. Crucially the expansion happens once per query, not once per document — the scoring loop only ever sees real indexed terms. **A tokenizer that splits compounds without losing them.** `CaesiumCLT` tokenized naively is one indivisible token, so a reader typing "caesium" matches nothing. Inserting a boundary before lowercasing fixes that — but then `WordPress` becomes `word` + `press`, and someone typing "wordpress" as one word matches neither half. The fix is a union rather than a replacement: keep both the split form and the whole form. ```ts title="src/components/AskMyBlog/utils.ts" export function tokenize(text: string): string[] { const withBoundaries = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); const split = withBoundaries.toLowerCase().match(/[a-z0-9]+/g) || []; const whole = text.toLowerCase().match(/[a-z0-9]+/g) || []; return [...new Set([...split, ...whole])]; } ``` ### The duplicate problem If forty articles each generate "How do I install Docker?", the index is useless — the reader gets forty identical rows and no signal. The plugin normalizes every question and drops **every** colliding instance, including the first. That last detail is deliberate: a question that is not specific to exactly one article has already failed the prompt's own rule, so keeping one copy would still ship a bad entry. Across my corpus this removes 5 questions out of 2,055. The prompt is doing most of the work; this is the safety net. ### Sidecars are meant to be hand-edited Generation is a first draft. Most entries are fine, some are a joke line lifted straight out of an article's prose and reformatted as a question. A tiny interactive pruner finds them by keyword and lets you delete by number: ```console $ yarn questions:edit "dinosaur" blog/2024/05/17/some-article/index.md.questions.json [0] How do I install the CLI? → #installation [1] How can I play with a green dinosaur? [2] What does the --force flag do? → #options Delete which number(s)? (space-separated, Enter to skip): 1 ✅ Removed 1 question(s), 11 left. ``` Deleting a question never touches the article itself, which matters for the last piece. ### Detecting drift A generated question can silently rot: rename a heading and its anchor breaks; rewrite a section and the questions stop describing it. `yarn questions:check` compares each sidecar's stored `sourceHash` against the article's current content and reports `STALE`, plus articles that have no sidecar at all. It is report-only by default; `--strict` fails on stale entries but never on missing coverage, because a freshly written article legitimately has no questions until you run the generator for it. **scripts/check-questions-freshness.mjs** ```mjs #!/usr/bin/env node /** * Detect drift between *.questions.json sidecar files and the article they describe, and * flag published articles that have no sidecar at all yet (a new article, or one written * before this pipeline existed). A source edit can invalidate generated questions (a removed * heading breaks an anchor, a rewritten section makes a question stop matching) without * anything catching it — this flags that drift the same way check-eli5-freshness.mjs does * for .eli5.json. Coverage (missing sidecars) has no equivalent in the eli5 checker, since * eli5 annotations are opt-in per snippet, not expected for every article. * * Usage: * node scripts/check-questions-freshness.mjs [--strict] [--quiet] * * With --quiet: print nothing at all unless something is actually actionable (a stale or * orphaned sidecar, or a published article with no sidecar yet). Counts and review progress * are suppressed. This is what the pre-commit hook runs — the hook is verbose, so anything * the script prints shows up on every single commit. Run it by hand (`yarn questions:check`) * for the full report. * * Without --strict: report-only, always exits 0. * With --strict: exits 1 if any STALE or ORPHANED sidecar is found. Missing coverage never * fails --strict — a freshly written article legitimately has no questions yet until someone * runs `yarn questions` for it; this check only surfaces that, it doesn't gate commits on it. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { execFileSync } from "child_process"; import { hashSource } from "./lib/eli5-hash.mjs"; import { loadPosts } from "./lib/blog-corpus.mjs"; import { questionCandidates } from "./lib/i18n-eligibility.mjs"; import { cmd } from "./lib/cheatsheet-hint.mjs"; import { orphanFixHint } from "./lib/orphan-hint.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const strict = process.argv.includes("--strict"); const quiet = process.argv.includes("--quiet"); // Enumerate via git rather than a filesystem walk — see check-eli5-freshness.mjs for why // (sibling worktrees under the project root would otherwise be double-counted). const gitListArgs = [ "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "blog/*.questions.json", // Localized sidecars (TODO 0120) sit next to the translated article, so the same // "strip the suffix, hash the source" check applies: a retranslation makes them stale. "i18n/*/docusaurus-plugin-content-blog/*.questions.json", ]; const relFiles = execFileSync("git", gitListArgs, { cwd: projectRoot, encoding: "utf-8" }) .split("\0") .filter(Boolean) // An article can *show* a sample sidecar in its own files/ folder // (blog/2026/09/28/docusaurus-ask-my-blog/files/demo.questions.json): documentation, not a // corpus sidecar, which always sits next to index.md. Scoping to blog/ was not enough — once // the draft was published it was reported ORPHANED again, and the printed `rm` hint got the // file deleted, breaking the build on a dangling . .filter((f) => !f.split("/").includes("files")); const files = relFiles.map((f) => path.resolve(projectRoot, f)); let fresh = 0, stale = 0, excluded = 0, orphaned = 0; // Collected so the summary can offer one copy-pasteable command covering every stale sidecar // — a batch of edited articles routinely produces eight or nine of these at once. const staleSources = []; // Localized stale sidecars are regenerated per locale by the eligibility-driven bulk run, // never with the English one-file command. const staleLocales = new Set(); for (const jsonPath of files) { const relJson = path.relative(projectRoot, jsonPath); const sourcePath = jsonPath.slice(0, -".questions.json".length); const relSource = path.relative(projectRoot, sourcePath); if (!fs.existsSync(sourcePath)) { console.warn(`⚠ ORPHANED — source missing for ${relJson}`); for (const line of orphanFixHint(relSource, relJson, projectRoot)) console.warn(line); orphaned++; continue; } let record; try { record = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); } catch { console.warn(`⚠ UNREADABLE — ${relJson} is not valid JSON`); console.warn(` Fix with: ${cmd("questions", "--force", relSource)}`); orphaned++; continue; } // Articles deliberately excluded during review (scripts/questions-review.mjs) carry no // questions on purpose, so there is nothing to be stale about — editing the article must // not put them back on the "regenerate me" list. if (record.excluded === true) { excluded++; continue; } const currentHash = hashSource(fs.readFileSync(sourcePath, "utf-8")); if (currentHash !== record.sourceHash) { // No per-file warning here — staleSources feeds the single batched command printed // below, so nothing tempts a one-by-one `questions --force` per file. const localeMatch = relSource.match(/^i18n\/([^/]+)\//); if (localeMatch) staleLocales.add(localeMatch[1]); else staleSources.push(relSource); stale++; } else { fresh++; } } // Coverage — published articles (drafts already excluded by loadPosts()) with no sidecar at // all yet: a new article, or one written before this pipeline existed. Not a freshness // problem (nothing to compare against), just a gap worth surfacing. const missing = loadPosts().filter( (post) => !fs.existsSync(`${post.file}.questions.json`), ); // Localized coverage — translated articles whose English questions exist but whose localized // ones do not yet. Stale ones are already counted above. const i18nRoot = path.join(projectRoot, "i18n"); const locales = fs.existsSync(i18nRoot) ? fs .readdirSync(i18nRoot, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) : []; const missingLocalized = locales.flatMap((locale) => questionCandidates(projectRoot, locale) .eligible.filter((file) => !fs.existsSync(`${file}.questions.json`)) .map((file) => ({ locale, file })), ); // In quiet mode the warnings above (if any) are the whole message — the counts below are // context that only makes sense when someone asked for a report. const actionable = stale > 0 || orphaned > 0 || missing.length > 0 || missingLocalized.length > 0; if (!quiet || actionable) { console.log( `\nquestions freshness: ${fresh} fresh, ${stale} stale, ${excluded} excluded, ${orphaned} orphaned (of ${files.length} sidecar file(s)).`, ); } if (staleSources.length > 0) { const list = staleSources.map((f) => `"${f}"`).join(" "); console.log( `⚠ STALE — ${staleSources.length} file(s) changed since their sidecar was generated:`, ); console.log(` for f in ${list}; do ${cmd("questions", "--force", '"$f"')}; done`); } for (const locale of staleLocales) { console.log(`⚠ STALE — "${locale}" question sidecar(s) behind their translation:`); console.log(` ${cmd("questions", "--locale", locale, "--all")}`); } if (missingLocalized.length > 0) { const byLocale = new Map(); for (const { locale } of missingLocalized) byLocale.set(locale, (byLocale.get(locale) || 0) + 1); for (const [locale, count] of byLocale) { console.log( `questions coverage (${locale}): ${count} translated article(s) with no sidecar yet — ` + cmd("questions", "--locale", locale, "--all"), ); } } if (!quiet || missing.length > 0) { console.log( `questions coverage: ${missing.length} published article(s) with no sidecar yet.`, ); } if (missing.length > 0) { // Capped — this is a report-only hook that can run on every commit; a 235-line dump (the // corpus-wide count before the bulk run has caught up) would drown the real signal. const MAX_LISTED = 10; for (const post of missing.slice(0, MAX_LISTED)) { console.log(` - ${cmd("questions", path.relative(projectRoot, post.file))}`); } if (missing.length > MAX_LISTED) { console.log( ` … and ${missing.length - MAX_LISTED} more — cover them all with: ` + cmd("questions:bulk"), ); } } // Human review progress — the sidecars are a first draft from a small local model until // someone has read them (see scripts/questions-review.mjs). const reviewed = files.filter((jsonPath) => { try { return Boolean(JSON.parse(fs.readFileSync(jsonPath, "utf-8")).reviewed); } catch { return false; } }).length; if (!quiet) { console.log( `questions review: ${reviewed}/${files.length} sidecar(s) reviewed by hand.` + (reviewed < files.length ? ` Continue with: ${cmd("questions:review")}` : ""), ); } if (strict && (stale > 0 || orphaned > 0)) { process.exit(1); } ``` ### The topic pages carry FAQ structured data Each `/faq/` page emits `FAQPage` JSON-LD. Worth being honest about the payoff: Google restricted FAQ rich results in August 2023 to a narrow set of authoritative government and health sites, so a personal blog will not get the expandable snippet UI out of it. It is still correct, standards-compliant markup that other consumers read — just not a guaranteed win in the search results page. ## Conclusion I started this wanting semantic search and ended up not needing any semantics at runtime. The model did the hard part once, offline, on my own machine, for free — and what it left behind was 2,160 plain English sentences that ordinary lexical ranking handles beautifully. That reframing is worth carrying elsewhere. "This needs a model" is very often "this needed a model, once, and now it needs a file". The version of your feature that runs a language model in every visitor's browser and the version that ships a JSON file computed at build time can produce the same answer — and only one of them still works when the API key expires. My whole index, generated locally and committed to Git, weighs 63 KB on the wire. That is smaller than the banner image at the top of this article. --- # The Third Door: A Floating Chat Bubble, and the Feature Nobody Could Find > A feature your visitors cannot find does not exist. This article adds a floating "Ask my blog" bubble to a Docusaurus site — a third entry point into the same question index, and the first one that needs no prior knowledge. Includes the overlay mutual-exclusion trick that keeps two independent floating dialogs from ever opening at once. --- ![The Third Door: A Floating Chat Bubble, and the Feature Nobody Could Find](/img/v2/ask-my-blog-chatbox.webp) **TL;DR** I had built a corpus map, a 2,050-question index and a six-mode command palette — and then noticed that every way into the question index required already knowing it was there. This article is the cheap half of that story: a floating chat bubble that opens the question search from any page, and a first-visit hint that mentions the keyboard shortcut once. Plus the small pub-sub trick that guarantees two independent floating dialogs never end up open at the same time. There is a particular kind of embarrassment reserved for people who build things alone. I had spent weeks on features I was genuinely proud of: an interactive [map of the whole blog](/blog/docusaurus-blog-map), a [question index](/blog/docusaurus-ask-my-blog) generated by a local model, a [command palette](/blog/?q=docker) with six modes. Then I asked myself a simpler question than usual: how would a reader who has never been here find the question index? There were exactly two ways in. Press `Ctrl`+`K`, then `?` to switch the palette into question mode — which requires knowing both the shortcut and that the mode exists. Or navigate to `/faq`, which requires knowing the page is there. Both doors were open. Both of them only worked for someone who had already read the article announcing them, which is to say: me. **Quick Jump:** [What Changed](#what-changed) | [Building the Bubble](#building-the-bubble) ## What Changed Two things, neither of them a new feature: ```plaintext 1. A floating bubble 2. A first-visit pill ┌────────────────┐ ┌─────────────────┐ │ 🦫 Ask my blog │ │ Press ⌘K to │ ├────────────────┤ │ search ✕ │ │ [Ask a questi…]│ └─────────────────┘ │ › How do I … │ once per browser, │ › What is … │ then never again └────────────────┘ bottom-right, every page ``` The bubble is the interesting one, because it is the **third** way into exactly the same question index: `Ctrl+K` then `?`, the standalone `/faq` page, and now a chat-shaped button that follows the reader around. Same corpus, same ranking, same results — three doors. ## Why Three Doors Is Not Redundancy I resisted the bubble for a while, on the grounds that the palette already did the job. That was wrong, and here is the reasoning that changed my mind: * **Each door serves a different reader.** The palette serves people who already know it exists. `/faq` serves search engines and people who want to browse a topic. The bubble serves the reader who is stuck in the middle of an article and would never think to press a keyboard shortcut. * **A floating button is the one affordance everyone already understands.** Nobody needs to be taught what a chat bubble in the corner does. That is precisely why it is worth spending a corner on — and why the panel header wears the same [meerkat mascot](/blog/docusaurus-easter-eggs) that shows up everywhere else on this site. * **It costs almost nothing to add.** The bubble does not reimplement search — it mounts the existing search component inside a panel. About 150 lines, most of them focus management. * **The expensive part was already paid for.** The question corpus, the ranking, the lazy fetch: all of it existed. Adding an entry point to a built feature is the highest-leverage work available, and it is the work that gets skipped. There is a limit to this reasoning, and it is worth stating before you go and add a fourth door. The test is not "how many entry points can I fit?" but "does this one reach a reader the others miss?". I had also planned a homepage tile pointing at the palette, and dropped it: the navbar already shows a permanently visible search box with the shortcut printed in it, on every page. Three doors serving three different readers, yes. Three reminders of the same door, no. ## Building the Bubble The component is deliberately thin. When the panel is closed, nothing search-related is mounted at all — so the 468 KB question corpus is still only fetched by a reader who actually opens it: **Project: src/components/AskMyBlogWidget** **src/components/AskMyBlogWidget/index.tsx** ```typescript /** * AskMyBlogWidget — a third, always-visible entry point into "Ask my blog" (alongside * Ctrl+K → `?` and the standalone /faq page): a floating bubble, bottom-right, that opens a * small panel wrapping the existing search box (src/components/AskMyBlog). It * doesn't reimplement search — is only mounted once the panel is actually open, * so the question corpus fetch stays on-demand exactly as it already is for CommandPalette's * `?` mode (see AskMyBlog/questionsIndex.js for why that fetch is lazy). * * Reserves a fixed slot stacked above the scroll-to-top button's corner * (src/components/ScrollToTopButton, mounted on every page by src/theme/Layout) rather than * detecting its presence at runtime — that button stays hidden until the reader scrolls, and the * empty space above it meanwhile is harmless. */ import { useCallback, useEffect, useRef, useState, type JSX } from "react"; import { translate } from "@docusaurus/Translate"; import AskMyBlog from "@site/src/components/AskMyBlog"; import { setActiveOverlay } from "@site/src/components/CommandPalette/paletteBus"; import mascot from "@site/static/img/ask_my_blog.webp"; import styles from "./styles.module.css"; const HINT_STORAGE_KEY = "askmyblog_bubble_hint_shown"; const HINT_DELAY_MS = 15_000; export default function AskMyBlogWidget(): JSX.Element { const [open, setOpen] = useState(false); const [showHint, setShowHint] = useState(false); const panelRef = useRef(null); const bubbleRef = useRef(null); const clearOverlayRef = useRef<(() => void) | null>(null); // First-visit discoverability, shown at most once ever — same mechanic as // CommandPalette/Hint.tsx, offset from its 10s delay so the two don't animate in together. useEffect(() => { if (localStorage.getItem(HINT_STORAGE_KEY)) return undefined; const timer = setTimeout(() => { localStorage.setItem(HINT_STORAGE_KEY, "1"); setShowHint(true); }, HINT_DELAY_MS); return () => clearTimeout(timer); }, []); const close = useCallback(() => { setOpen(false); clearOverlayRef.current?.(); bubbleRef.current?.focus(); }, []); const toggle = () => { setShowHint(false); if (open) { close(); return; } setOpen(true); clearOverlayRef.current = setActiveOverlay(close); }; useEffect(() => { if (!open) return undefined; panelRef.current?.querySelector("input")?.focus(); function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); close(); } } function onTab(event: KeyboardEvent) { if (event.key !== "Tab" || !panelRef.current) return; const focusable = panelRef.current.querySelectorAll( 'button, [href], input, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onTab); return () => { document.removeEventListener("keydown", onKeyDown); document.removeEventListener("keydown", onTab); }; }, [open, close]); return ( <> {open && (
{translate({ id: "askMyBlogWidget.title", message: "Ask my blog" })}
)} ); } ``` **src/components/AskMyBlogWidget/styles.module.css** ```css .bubble { position: fixed; right: 30px; /* 16px clear of the scroll-to-top button below it (ScrollToTopButton: bottom 30px, 60px tall → top edge at 90px) — the two used to sit 10px apart, easy to mis-tap in the bottom-right thumb zone. */ bottom: 106px; z-index: 999; display: flex; align-items: center; justify-content: center; width: 60px; height: 60px; padding: 0; cursor: pointer; background: var(--mk-primary); border: none; border-radius: 50%; box-shadow: var(--mk-shadow-3); transition: transform 0.2s, background 0.2s; } .bubble:hover { background: var(--ifm-color-primary-dark); transform: scale(1.06); } .bubble:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 3px; } @media (prefers-reduced-motion: reduce) { .bubble, .bubble:hover { transform: none; transition: none; } } .bubbleIcon { width: 30px; height: 30px; color: var(--mk-on-line); } .pulseRing { position: absolute; inset: -4px; border: 2px solid var(--mk-primary); border-radius: 50%; animation: askPulse 2.2s ease-out 3; pointer-events: none; } @media (prefers-reduced-motion: reduce) { .pulseRing { display: none; animation: none; } } @keyframes askPulse { 0% { opacity: 0.7; transform: scale(1); } 100% { opacity: 0; transform: scale(1.7); } } @media (max-width: 600px) { .bubble { right: 16px; /* Same 16px clearance above the mobile scroll-to-top button (bottom 16px, 44px tall → top edge at 60px). */ bottom: 76px; width: 44px; height: 44px; } .bubbleIcon { width: 22px; height: 22px; } } .panel { position: fixed; right: 30px; bottom: 176px; z-index: 999; display: flex; flex-direction: column; width: min(360px, calc(100vw - 2rem)); max-height: min(480px, calc(100vh - 200px)); overflow: hidden; background: var(--mk-surface); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-lg); box-shadow: var(--mk-shadow-3); animation: panelIn 0.15s ease-out; } @media (prefers-reduced-motion: reduce) { .panel { animation: none; } } @keyframes panelIn { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } .panelHead { display: flex; align-items: center; justify-content: space-between; padding: 0.55rem 1rem; font-size: 0.95rem; font-weight: 700; color: var(--mk-on-line); background: var(--mk-primary); } .panelHeadTitle { display: flex; align-items: center; gap: 0.5rem; } .panelHeadMascot { flex-shrink: 0; object-fit: cover; border: 2px solid var(--mk-on-line); border-radius: 50%; } .panelClose { padding: 0.1rem 0.3rem; font-size: 1.3rem; line-height: 1; color: var(--mk-on-line); opacity: 0.85; background: none; border: none; cursor: pointer; } .panelClose:hover { opacity: 1; } .panelBody { padding: 0.75rem 1rem 1rem; overflow-y: auto; } @media (max-width: 600px) { .panel { right: 16px; left: 16px; bottom: 128px; width: auto; } } ``` It is mounted once per page, in the same `Layout` swizzle that mounts the palette: ```javascript title="src/theme/Layout/index.js" export default function LayoutWrapper(props) { return ( {props.children} ); } ``` One layout decision worth copying: the bubble reserves a **fixed slot stacked above** the [scroll-to-top button](/blog/docusaurus-go-top)'s corner, rather than detecting that button's presence at runtime. Scroll-to-top is not mounted on every page, and a bit of empty space above it when it is absent is completely harmless — far more harmless than two floating buttons racing to occupy the same 60 pixels. ## The Two-Overlays Problem The palette and the bubble panel are independent floating dialogs, each openable from several places, and neither knows the other exists. Open one while the other is open and you get two modals stacked, two focus traps fighting, and two `Esc` handlers. The fix is eight lines, and it lives in the palette's own bus module — so neither component has to import the other: ```typescript title="src/components/CommandPalette/paletteBus.ts" let activeCloser = null; export function setActiveOverlay(closeFn) { if (activeCloser && activeCloser !== closeFn) activeCloser(); activeCloser = closeFn; return () => { if (activeCloser === closeFn) activeCloser = null; }; } ``` Each dialog calls `setActiveOverlay(close)` when it opens, and calls the cleanup function it gets back when it closes. Whichever opens second closes whichever was open first. Adding a third overlay later requires changing nothing. ## A Hint That Is Allowed to Appear Exactly Once (skip this if you just want the bubble) Both the palette's pill and the bubble's pulse ring are first-visit hints, and both follow the same three rules: * The `localStorage` flag is written **when the hint is shown**, not when it is dismissed. A visitor who ignores it does not get it again on their next page. * The delays are staggered — the palette's pill at 4 or 10 seconds, the bubble's pulse at 15 — so the two never animate in together and read as a pop-up. * The pulse ring is removed entirely under `prefers-reduced-motion`, not just slowed down. The palette's delay is deliberately different per page type, and the reason is worth stating: 10 seconds on an article exists so the pill never interrupts someone who has just started reading. The homepage has no reading to interrupt and is where visitors bounce fastest, so it gets 4 seconds — still long enough not to look like a load-time pop-up. ```typescript title="src/components/CommandPalette/Hint.tsx" function delayFor(pathname) { if (HOME_PATH.test(pathname)) return HOME_DELAY_MS; if (ARTICLE_PATH.test(pathname)) return ARTICLE_DELAY_MS; return null; } ``` Returning `null` — rather than a large number — is what makes "this path shows nothing" a distinct, readable state instead of a delay you have to squint at. ## Conclusion The features in this series took weeks. Making them findable took an afternoon, and it is entirely possible that the afternoon mattered more. There is a failure mode specific to building your own tools: you are the one user who can never evaluate discoverability, because you cannot un-know where things are. Every door looks obvious from the inside. The only fix I have found is mechanical — list the entry points to a feature, and for each one write down what the reader has to already know in order to use it. Anything whose answer is "that this feature exists" is not an entry point at all. And when in doubt, put a button in the corner. Everybody knows what it does. --- # Drawing a Map of My Own Blog > Build an interactive map of your whole Docusaurus corpus — every article as a bubble wearing the site's mascot, sized by how many other posts link to it, positioned by a force-directed layout that runs at build time in Node so the browser never ships d3-force. Includes the plugin, the canvas component, and the plain-list fallback that keeps the page usable with JavaScript disabled. --- ![Drawing a Map of My Own Blog](/img/v2/post_mindmaps.webp) **TL;DR** After 262 articles I could no longer answer simple questions about my own blog: which posts are hubs that everything links to, and which ones sit alone in a corner. So I built a `/map` page — a force-directed graph of the whole corpus, where each article is a dot sized by its in-degree and connected by three kinds of edge (real inline links, series neighbors, shared tags). The trick that makes it cheap: the layout runs **once, in Node, at build time**, so the browser receives final `(x, y)` coordinates and never loads a physics engine. I publish twice a week, on Mondays and Thursdays, and I have been doing it for a while. Somewhere around post number 150 I stopped being able to answer questions I should have known by heart: is that `fzf` article actually linked from anywhere? Which post is the one everything else points back to? Did the whole Quarto series end up isolated from the rest of the blog? I had the data — every article's frontmatter, every inline link — but no way to *look* at it. A list of 262 rows is not a way to look at anything. So I built [/map](/map) — a page that draws the blog as a graph. Here is how it works and how to build one. **Quick Jump:** [What the Map Page Shows You](#what-the-map-page-shows-you) | [Building It](#building-it) ## What the Map Page Shows You Every published article is a bubble. The bigger it is, the more other articles link to it. Lines connect posts that are genuinely related — hover one and everything that is not its direct neighbor dims away: ```plaintext title="/map" ┌─ Blog Map ──────────────────────────────────────────────────────────────────┐ │ │ │ Filter by topic [ Top 120 most-linked articles ▾ ] │ │ 262 articles · 25 series · 680 internal links │ │ │ │ Running Docusaurus with Docker │ │ · · ● │ │ · ╲ │ ╱ · ╱ ╲ │ │ ·──── (◉) ────· ● ●───────● Docker-out-of-Docker │ │ · ╱ │ ╲ · ╲ ╱ │ │ · · ● │ │ 🦫 │ │ (drawn bigger than its size: │ │ nothing links to this one yet)│ │ │ │ ▸ View as list instead │ └─────────────────────────────────────────────────────────────────────────────┘ ``` Three things worth doing on that page: * **Filter by topic** using the dropdown to narrow the graph to one `mainTag`. The articles that share the tag but link to nothing around them stay visible — that is exactly the kind of orphan you would otherwise never notice. * **Hover a node** to see its title and highlight its direct connections. A cluster that looks dense from a distance often turns out to be two or three hubs with a lot of satellites that only link inward. * **Switch to list** at the bottom if you prefer prose over canvas, or if you have JavaScript disabled: every article is still there, grouped by series, fully indexed. ## Why Computing the Layout at Build Time Changes Everything A force-directed graph is normally a browser thing: you ship a physics library, drop the nodes in, and watch them settle over a few seconds. That approach fails three requirements I actually cared about, and moving the simulation into Node fixes all three at once. * **The page has to work with JavaScript disabled.** If the positions are already final in the HTML payload, the only thing JavaScript adds is the drawing — so the no-JS path can fall back to a plain, server-rendered, fully indexable list of every article instead of an empty box. * **Nothing may animate on its own.** There is no `prefers-reduced-motion` branch to write, because there is no motion to suppress: the graph is already at rest the instant it appears. * **`d3-force` never reaches the reader.** It stays a build-time dependency. The browser gets numbers, not a solver. * **The same build produces the same map.** Node order is sorted before the simulation starts, and `d3-force` places nodes from their array index rather than from `Math.random()`, so a rebuild with no content change reproduces the exact same picture. The cost of all that is one extra second in `yarn build`. It is the best trade I have made on this blog. ## Building It Two pieces: a plugin that computes the graph, and a component that draws it. ### The plugin It reads the corpus through the same loader my [tag tooling](/blog/docusaurus-tags) already uses, builds the edges, colors each node, runs 300 simulation ticks, and hands the result to `setGlobalData`: **plugins/blog-graph-plugin/index.mjs** ```mjs /** * Docusaurus Plugin: blog-graph * * Purpose: * Builds the corpus-wide node/edge graph (link, series and tag relationships between every * published article) that powers the `/map` page's `BlogGraph` component — see * `.todos/DONE/DONE_0081-blog-knowledge-graph-map-page.md` for the full spec. * * Why the force-directed layout is computed *here*, at build time, instead of in the * browser: * `/map`'s non-negotiable constraints (works with JS disabled, static rendering under * `prefers-reduced-motion`, indexable without hydration) all fall out for free once the * *positions* are already final by the time the page reaches the browser — so the * simulation runs once, in Node, and only the converged `(x, y)` per node ships to the * client. Nothing in the spec asks for live drag-to-reposition, so the client never needs * `d3-force` at all: the ~20 Ko dependency stays a build-time-only cost, out of the browser * bundle entirely. * * Data flow: * - `loadContent()` runs on every `yarn start` reload and once for `yarn build`. It reads * the same corpus `scripts/internal-link-opportunities.mjs` uses * (`scripts/lib/blog-corpus.mjs`, extracted for exactly this reason — see that file's * header), builds nodes/edges, colors each node, then runs a deterministic `d3-force` * simulation to converge `(x, y)`. * - `contentLoaded()` exposes the result as global plugin data via `setGlobalData`, so * `src/pages/map.js` reads it with `usePluginData("blog-graph-plugin")` — available * during SSR (baked into the static HTML) and during hydration, no client-side fetch. * * Node color — *not* an invented palette: * A post's node reuses whichever accent color the site already assigns it elsewhere: the * hand-picked `series.color` (`src/data/series.js`) for posts that belong to a series — * the same color `SeriesArticlesPage` themes its hero with — else the post's own * auto-extracted banner accent (`src/data/postColors.generated.js`, keyed by the post's * `image` frontmatter), the same color `BlogPostItem/Content` themes its hero with. See * `src/components/Blog/utils/color.md`. A post with neither falls back to a neutral gray. * * Edge types: * - "link" — a real inline link from one post's prose to another (heaviest pull). * - "series" — two consecutive posts (by date) within the same series. * - "tag" — shared tags between two posts. Only pairs sharing >= 2 tags are emitted at * all: at this corpus size almost every pair of posts shares at least one tag, so a * weight-1 threshold would connect everything to everything and defeat both the layout * and the "don't draw the weak edges" rule from the spec. There is no separate * simulate-but-don't-draw step — dropping weight-1 pairs before the layout runs is a * simplification of the spec's threshold, made possible by the layout itself running * once at build time rather than live in the browser. */ import path from "node:path"; import { fileURLToPath } from "node:url"; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, } from "d3-force"; import { loadPosts } from "../../scripts/lib/blog-corpus.mjs"; import translationsManifest from "../translations-manifest-plugin/index.cjs"; const { collectTranslations, collectTranslatedFrontMatter } = translationsManifest; import SERIES_DATA from "../../src/data/series.js"; import POST_COLORS from "../../src/data/postColors.generated.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Virtual coordinate space the layout is computed in. The client normalizes/scales this to // whatever the actual canvas size is, so these are arbitrary but must stay fixed for the // simulation's forces (distance, center) to make sense relative to one another. const WIDTH = 1600; const HEIGHT = 1000; const MIN_NODE_RADIUS = 3; const MAX_NODE_RADIUS = 20; // Below this many shared tags, a pair of posts is not connected at all — see the "tag" bullet // in the module comment above. const MIN_SHARED_TAGS = 2; // The layout (computeLayout, below) uses every edge down to MIN_SHARED_TAGS for spatial // pull, but the client never runs a simulation and only ever draws a "tag" edge above a // stricter threshold — shipping the weight-2 pairs (the large majority: ~1150 of ~1350 tag // edges in this corpus) would triple the payload for edges nothing ever renders. This is the // build-time equivalent of the spec's "simulate but don't draw below a threshold" rule. const SHIP_MIN_SHARED_TAGS = 3; const FALLBACK_COLOR = "#94a3b8"; // Neutral slate — only used when a post has neither a // series color nor a banner accent (no image, or an image `generate-post-colors.mjs` // couldn't extract a vivid color from). const AVERAGE_READING_WPM = 200; /** Approximate reading time from the already-prose-stripped body text. */ function readingTimeMinutes(prose) { const words = prose.split(/\s+/).filter(Boolean).length; return Math.max(1, Math.round(words / AVERAGE_READING_WPM)); } /** Builds the "link" / "series" / "tag" edges between posts. */ function buildEdges(posts) { const known = new Set(posts.map((post) => post.permalink)); const edges = []; // "link" — real inline links, the strongest signal of relatedness. for (const post of posts) { for (const target of post.links) { if (target !== post.permalink && known.has(target)) { edges.push({ source: post.permalink, target, type: "link", weight: 3 }); } } } // "series" — consecutive posts (by date) within the same series. 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); } for (const seriesPosts of bySeries.values()) { seriesPosts.sort((a, b) => a.date.localeCompare(b.date)); for (let i = 0; i < seriesPosts.length - 1; i += 1) { edges.push({ source: seriesPosts[i].permalink, target: seriesPosts[i + 1].permalink, type: "series", weight: 2, }); } } // "tag" — shared tags, weight = number of shared tags. See MIN_SHARED_TAGS above. for (let i = 0; i < posts.length; i += 1) { for (let j = i + 1; j < posts.length; j += 1) { const shared = posts[i].tags.filter((tag) => posts[j].tags.includes(tag)); if (shared.length >= MIN_SHARED_TAGS) { edges.push({ source: posts[i].permalink, target: posts[j].permalink, type: "tag", weight: shared.length, }); } } } return edges; } /** In-degree (how many *other* articles link to this one) per permalink, from "link" edges only. */ function computeInDegree(posts, edges) { const inDegree = new Map(posts.map((post) => [post.permalink, 0])); for (const edge of edges) { if (edge.type === "link" && inDegree.has(edge.target)) { inDegree.set(edge.target, inDegree.get(edge.target) + 1); } } return inDegree; } /** Reuses the site's own already-established accent colors — never an invented palette. */ function buildColorPicker() { const seriesColorByName = new Map( SERIES_DATA.map((series) => [series.name, series.color]), ); return (post) => { if (post.series && seriesColorByName.has(post.series)) { return seriesColorByName.get(post.series); } if (post.image && POST_COLORS[post.image]) { return POST_COLORS[post.image]; } return FALLBACK_COLOR; }; } /** Radius grows with the square root of in-degree — linear would let hubs swallow the map. */ function buildRadiusScale(maxInDegree) { const safeMax = Math.max(maxInDegree, 1); return (inDegree) => MIN_NODE_RADIUS + (MAX_NODE_RADIUS - MIN_NODE_RADIUS) * Math.sqrt(inDegree / safeMax); } const EDGE_TUNING = { link: { distance: 55, strength: 0.85 }, series: { distance: 70, strength: 0.5 }, tag: { distance: 110, strength: 0.08 }, // scaled further by weight below }; /** * Runs the force simulation to convergence and returns `{ permalink, x, y }` for every node. * Deterministic: node order is sorted before the simulation starts (d3-force's default * initial placement is a function of array index, not `Math.random()`), so re-running the * build without any corpus change reproduces the same layout. */ function computeLayout(nodes, edges) { const sortedNodes = [...nodes].sort((a, b) => a.permalink.localeCompare(b.permalink)); const radiusByPermalink = new Map( sortedNodes.map((node) => [node.permalink, node.radius]), ); const simNodes = sortedNodes.map((node) => ({ id: node.permalink })); const simLinks = edges.map((edge) => ({ ...edge })); const simulation = forceSimulation(simNodes) .force( "link", forceLink(simLinks) .id((d) => d.id) .distance((edge) => EDGE_TUNING[edge.type].distance) .strength((edge) => { const { strength } = EDGE_TUNING[edge.type]; return edge.type === "tag" ? Math.min(strength * edge.weight, 0.6) : strength; }), ) .force("charge", forceManyBody().strength(-45)) .force("center", forceCenter(WIDTH / 2, HEIGHT / 2)) .force( "collide", forceCollide((d) => radiusByPermalink.get(d.id) + 2), ) .stop(); // Pre-converge synchronously instead of animating in the browser — this is the "simulation // pré-convergée au build" the reduced-motion/no-JS requirements depend on. const TICKS = 300; for (let i = 0; i < TICKS; i += 1) { simulation.tick(); } return new Map(simNodes.map((node) => [node.id, { x: node.x, y: node.y }])); } /** * Builds the full graph — nodes, edges, layout and corpus-wide counters. * * The graph is a listing surface like any other, and a non-default locale needs **two** things * from it, not one. `loadPosts()` reads `blog/`, i.e. the English corpus, and Docusaurus's i18n * fallback gives every one of those articles a live `/fr/` route: * * 1. filter — show only articles that are really translated, or a French reader clicks a node * and lands on English prose; * 2. localize — label the survivors with their French title, or the French map reads entirely * in English. Filtering alone is what shipped: the four translated articles were correctly * the only four nodes on `/fr/map/`, each carrying its English title. * * Both the canvas labels and the `` fallback read `node.title`, so the overlay * belongs here — once — rather than in either component. See TODO 0119. */ function buildGraph({ siteDir, currentLocale, defaultLocale } = {}) { let posts = loadPosts(); /** slug -> localized front matter. Empty on the default locale, where titles are the source. */ let localized = {}; if (currentLocale && defaultLocale && currentLocale !== defaultLocale) { const translated = new Set(collectTranslations(siteDir)[currentLocale] ?? []); posts = posts.filter((post) => { const match = String(post.permalink ?? "").match(/blog\/([^/]+)\/?$/); return match ? translated.has(match[1]) : false; }); localized = collectTranslatedFrontMatter(siteDir)[currentLocale] ?? {}; } const edges = buildEdges(posts); const inDegreeByPermalink = computeInDegree(posts, edges); const maxInDegree = Math.max(0, ...inDegreeByPermalink.values()); const radiusScale = buildRadiusScale(maxInDegree); const colorForPost = buildColorPicker(); const nodes = posts.map((post) => { const inDegree = inDegreeByPermalink.get(post.permalink) ?? 0; return { slug: post.slug, // The node label — the only text a reader sees on the map. `post.title` is the English // source; in a non-default locale the corpus above has already been narrowed to articles // that DO have a translation, so the overlay finds one for every surviving node. title: localized[post.slug]?.title || post.title, permalink: post.permalink, date: post.date, mainTag: post.mainTag, series: post.series, readingTime: readingTimeMinutes(post.prose), inDegree, radius: radiusScale(inDegree), color: colorForPost(post), }; }); const layout = computeLayout(nodes, edges); for (const node of nodes) { const position = layout.get(node.permalink); node.x = position.x; node.y = position.y; } const seriesCount = new Set(posts.map((post) => post.series).filter(Boolean)).size; const linkEdgeCount = edges.filter((edge) => edge.type === "link").length; // See SHIP_MIN_SHARED_TAGS above: the layout already used the full tag-edge set, this only // trims what ships to the browser. const shippedEdges = edges.filter( (edge) => edge.type !== "tag" || edge.weight >= SHIP_MIN_SHARED_TAGS, ); return { // No build timestamp here on purpose. This object ships inside the client // bundle, so a wall-clock value changes main.js's content hash on every // build, which renames the file, which changes every HTML page that // references it — ~44 MB re-uploaded on each FTP deploy for nothing. width: WIDTH, height: HEIGHT, nodes, edges: shippedEdges, meta: { articleCount: posts.length, seriesCount, linkCount: linkEdgeCount, }, }; } export default function blogGraphPlugin(context) { return { name: "blog-graph-plugin", async loadContent() { return buildGraph({ siteDir: context.siteDir, currentLocale: context.i18n?.currentLocale, defaultLocale: context.i18n?.defaultLocale, }); }, async contentLoaded({ content, actions }) { actions.setGlobalData(content); }, getPathsToWatch() { // Rebuild the graph in `yarn start` whenever an article's frontmatter/body or the // corpus-loading lib itself changes — not just on a hard restart. return [ path.join(__dirname, "../../blog/**/index.{md,mdx}"), path.join(__dirname, "../../scripts/lib/blog-corpus.mjs"), path.join(__dirname, "../../src/data/series.js"), path.join(__dirname, "../../src/data/postColors.generated.js"), ]; }, }; } ``` Running it standalone shows what the payload looks like: ```bash $ node --input-type=module -e " import('./plugins/blog-graph-plugin/index.mjs').then(async m => { const graph = await m.default().loadContent(); const byType = {}; for (const edge of graph.edges) byType[edge.type] = (byType[edge.type] ?? 0) + 1; console.log('nodes ', graph.nodes.length); console.log('edges ', graph.edges.length, byType); console.log('meta ', graph.meta); console.log('payload', JSON.stringify(graph).length, 'bytes'); });" nodes 262 edges 1026 { link: 680, series: 144, tag: 202 } meta { articleCount: 262, seriesCount: 25, linkCount: 680 } payload 183292 bytes ``` 262 nodes, 1026 edges, 179 KB of JSON — computed once at build time, never touched again by the browser. One decision in there is worth pulling out: **the node colors are not a new palette.** A post that belongs to a series reuses that series' own accent color from `src/data/series.js` — the same one the [series page](/blog/docusaurus-series) themes its hero with. A post without a series falls back to the accent color automatically extracted from its banner image. Only a post with neither gets a neutral gray. The map therefore looks like the rest of the site for free, and a reader who already recognizes "the blue one is the Quarto series" recognizes it here too. ### The component `BlogGraph` reads that global data, draws it on a ``, and swaps itself out for a plain list below 768px: **Project: src/components/BlogGraph** **src/components/BlogGraph/index.tsx** ```typescript /** * BlogGraph — the interactive corpus map rendered on `/map`. * * Reads the pre-computed graph (nodes, edges, build-time force layout) from * `plugins/blog-graph-plugin` via `usePluginData`, then: * - draws it on a `` (248 nodes + edges in the DOM would crawl on hover/drag — * see 0081's "Solution" §2), never more than the top ~120 articles by in-degree, or a * single mainTag's subgraph once one is picked from the filter; * - degrades to `GroupedList` (an SSR'd, fully indexable plain list) with JavaScript * disabled and on narrow viewports, where a force-directed graph has no room to be * legible. * * The layout itself is already static (computed once, in Node — see the plugin's header * comment) so there is no `prefers-reduced-motion` branch to build here: nothing animates * on its own in the first place. The only motion is the hover crossfade, which the * stylesheet already turns off under that media query. * * No client-side simulation, no drag-to-reposition — out of scope per the TODO (only hover * and click are specified), and dropping it keeps `d3-force` a build-time-only dependency. * * The nodes themselves are drawn as this blog's own meerkat mascot rather than as flat dots — * one sticker per mainTag, so a topic reads as a family at a glance. See ./meerkats for the * mapping, and MEERKAT_MIN_DRAWN_RADIUS in ./utils for the size below which a face stops being * legible and the node falls back to the colored dot it always was. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { useHistory } from "@docusaurus/router"; import { usePluginData } from "@docusaurus/useGlobalData"; import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import GroupedList from "./GroupedList"; import { DEFAULT_TOP_N, MEERKAT_MIN_DRAWN_RADIUS, PERMANENT_LABEL_COUNT, computeCanvasHeight, displayRadius, fitTransform, neighborsOf, pickOrphanNodes, selectVisibleEdges, selectVisibleNodes, toCanvasSpace, type BlogGraphData, type BlogGraphNode, type Transform, } from "./utils"; import { meerkatPathFor } from "./meerkats"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; /** * Sticker path (as `meerkatPathFor` returns it, before `withBaseUrl`) -> the decoded image, for * the stickers the current view actually needs. */ type MeerkatImages = Record; interface LabelCandidate { node: BlogGraphNode; x: number; y: number; dimmed: boolean; } const MOBILE_BREAKPOINT = 768; const DIMMED_ALPHA = 0.15; const HOVER_HIT_PADDING = 3; const LABEL_FONT = "12px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; const EDGE_ALPHA: Record = { link: 0.55, series: 0.4, tag: 0.18 }; const EDGE_WIDTH: Record = { link: 1.4, series: 1, tag: 0.7 }; const LABEL_HEIGHT = 14; const LABEL_PADDING = 2; /** * Greedily keeps only the labels that don't overlap an already-placed one, in priority * order (the caller sorts `candidates` — hovered node first, then its neighbors, then the * permanently-labeled hubs). A crowded mainTag filter can otherwise stack a dozen titles on * top of each other, which is exactly the "hairball" outcome the spec says must not ship. */ function selectNonOverlappingLabels( ctx: CanvasRenderingContext2D, candidates: LabelCandidate[], ): LabelCandidate[] { const placed: LabelCandidate[] = []; const drawnRects: { left: number; right: number; top: number; bottom: number }[] = []; for (const candidate of candidates) { const width = ctx.measureText(candidate.node.title).width; const rect = { left: candidate.x - LABEL_PADDING, right: candidate.x + width + LABEL_PADDING, top: candidate.y - LABEL_HEIGHT / 2, bottom: candidate.y + LABEL_HEIGHT / 2, }; const overlapsExisting = drawnRects.some( (drawn) => rect.left < drawn.right && rect.right > drawn.left && rect.top < drawn.bottom && rect.bottom > drawn.top, ); if (!overlapsExisting) { drawnRects.push(rect); placed.push(candidate); } } return placed; } /** Reads the current theme's own colors so the canvas never hardcodes a hex value. */ function readThemeColors(): { edge: string; label: string } { const style = getComputedStyle(document.documentElement); return { edge: style.getPropertyValue("--ifm-color-emphasis-500").trim() || "#a2917a", label: style.getPropertyValue("--ifm-font-color-base").trim() || "#1c1e21", }; } /** * The sticker to draw inside a node, or null when it shouldn't wear one: too small for a face * to read (see MEERKAT_MIN_DRAWN_RADIUS), or simply not downloaded yet — in both cases the * caller falls back to the flat colored dot. */ function resolveMeerkatImage( node: BlogGraphNode, radius: number, images: MeerkatImages, ): HTMLImageElement | null { if (radius < MEERKAT_MIN_DRAWN_RADIUS) return null; const path = meerkatPathFor(node); return (path && images[path]) || null; } /** Draws `image` centered on (cx, cy), scaled to cover a `size` × `size` box (crop, not stretch). */ function drawImageCover( ctx: CanvasRenderingContext2D, image: HTMLImageElement, cx: number, cy: number, size: number, ) { const naturalWidth = image.naturalWidth || image.width; const naturalHeight = image.naturalHeight || image.height; const scale = Math.max(size / naturalWidth, size / naturalHeight); const drawWidth = naturalWidth * scale; const drawHeight = naturalHeight * scale; ctx.drawImage(image, cx - drawWidth / 2, cy - drawHeight / 2, drawWidth, drawHeight); } export default function BlogGraph() { const graph = usePluginData("blog-graph-plugin") as BlogGraphData | undefined; const history = useHistory(); const { withBaseUrl } = useBaseUrlUtils(); const { i18n } = useDocusaurusContext(); const tagLabel = useTagLabel(); const wrapRef = useRef(null); const canvasRef = useRef(null); const [mounted, setMounted] = useState(false); const [isMobile, setIsMobile] = useState(false); const [mainTag, setMainTag] = useState(""); const [hovered, setHovered] = useState(null); const [containerWidth, setContainerWidth] = useState(0); // Bumped on theme toggle so the draw effect re-reads the (now different) CSS variables — // canvas has no way to react to a CSS variable change on its own. const [themeVersion, setThemeVersion] = useState(0); // Starts empty, so the draw effect just falls back to a flat color until (and unless) an // image is ready. const [meerkatImages, setMeerkatImages] = useState({}); // Every URL a load has already been started for — including the ones that failed, which are // deliberately never retried: a node that can't get its sticker keeps its colored dot, and a // broken asset must not turn into one request per redraw. const requestedRef = useRef(new Set()); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- SSR-safe mount detection, same pattern as ScrollToTopButton/reactions-dashboard setMounted(true); const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); const updateIsMobile = () => setIsMobile(mediaQuery.matches); updateIsMobile(); mediaQuery.addEventListener("change", updateIsMobile); return () => mediaQuery.removeEventListener("change", updateIsMobile); }, []); useEffect(() => { const observer = new MutationObserver(() => setThemeVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); return () => observer.disconnect(); }, []); // Progressive enhancement only: canvas graph once mounted on a wide-enough viewport, // the plain grouped list everywhere else (no JS at all, or a narrow screen). const showCanvas = mounted && !isMobile; useEffect(() => { if (!showCanvas || !wrapRef.current) return undefined; const el = wrapRef.current; const updateWidth = () => setContainerWidth(el.clientWidth); updateWidth(); const resizeObserver = new ResizeObserver(updateWidth); resizeObserver.observe(el); return () => resizeObserver.disconnect(); }, [showCanvas]); const mainTags = useMemo(() => { if (!graph) return []; const keys = [ ...new Set(graph.nodes.map((node) => node.mainTag).filter(Boolean)), ] as string[]; // Sorted on the DISPLAYED label, not on the key: under `fr`, "ai" reads "Intelligence // artificielle (IA)" and sorting by key would order the dropdown by invisible values. // `currentLocale` is passed explicitly — the runtime default differs between the Node // render and the browser, which would reorder the list at hydration. return keys.sort((a, b) => tagLabel(a).localeCompare(tagLabel(b), i18n.currentLocale), ); }, [graph, tagLabel, i18n.currentLocale]); const visibleNodes = useMemo(() => { if (!graph) return []; return selectVisibleNodes(graph.nodes, { mainTag: mainTag || null, topN: DEFAULT_TOP_N, }); }, [graph, mainTag]); // Height fits the *currently visible* nodes, not a constant tuned for the full 120-node // view — see computeCanvasHeight() for why a two-node mainTag filter needs two independent // signals (shape *and* count) to reliably get a short canvas instead of inheriting the // default view's height with almost everything empty. const canvasSize = useMemo( () => ({ width: containerWidth, height: computeCanvasHeight(visibleNodes, containerWidth), }), [containerWidth, visibleNodes], ); const visiblePermalinks = useMemo( () => new Set(visibleNodes.map((node) => node.permalink)), [visibleNodes], ); const nodeByPermalink = useMemo( () => new Map(visibleNodes.map((node) => [node.permalink, node])), [visibleNodes], ); const visibleEdges = useMemo(() => { if (!graph) return []; return selectVisibleEdges(graph.edges, visiblePermalinks); }, [graph, visiblePermalinks]); const orphanNodes = useMemo( () => pickOrphanNodes(visibleNodes, visibleEdges), [visibleNodes, visibleEdges], ); // Only the stickers this view needs, not all 68: the default view uses one per mainTag among // its 120 nodes, and a filtered view exactly one. `.sort().join()` keeps the dependency a // stable string, so switching filters back and forth doesn't re-run the effect for a set // that is already loaded. const meerkatPaths = useMemo(() => { const paths = new Set(); for (const node of visibleNodes) { const path = meerkatPathFor(node); if (path) paths.add(path); } return [...paths].sort().join(" "); }, [visibleNodes]); useEffect(() => { const missing = meerkatPaths .split(" ") .filter((path) => path && !requestedRef.current.has(path)); if (missing.length === 0) return undefined; for (const path of missing) requestedRef.current.add(path); let cancelled = false; const load = (path: string) => new Promise<[string, HTMLImageElement | null]>((resolve) => { const image = new Image(); image.onload = () => resolve([path, image]); // A failed load (offline, blocked asset, a sticker renamed since the last build) just // means that node keeps its flat color — never a reason to break the graph. image.onerror = () => resolve([path, null]); // The images live in `static/`, which each locale build copies under its own prefix: // without withBaseUrl, /fr/map/ would request /img/... and get the SPA fallback. image.src = withBaseUrl(path); }); Promise.all(missing.map(load)).then((entries) => { if (cancelled) return; const loaded: MeerkatImages = {}; for (const [path, image] of entries) { if (image) loaded[path] = image; } if (Object.keys(loaded).length > 0) { setMeerkatImages((previous) => ({ ...previous, ...loaded })); } }); return () => { cancelled = true; }; }, [meerkatPaths, withBaseUrl]); // The list fallback (mobile, no JS, or the "View as list" disclosure) ignores the top-N // cutoff — a plain list has no legibility ceiling the way a canvas does — but must still // respect the mainTag filter: otherwise picking a topic from the setMainTag(event.target.value)} > {mainTags.map((tag) => ( ))}

{"{articles} articles · {series} series · {links} internal links"}

{showCanvas && ( Your browser does not support the canvas element — see the list below instead. )} {showCanvas ? (
View as list instead
) : ( )}
); } ``` **src/components/BlogGraph/utils.ts** ```typescript /** * Pure helpers for BlogGraph — kept separate from the canvas/React code so the filtering, * fit-to-viewport and labeling logic can be read (and changed) without wading through event * handlers. */ // Default view: the corpus is never shown all at once (248 nodes is a hairball) — only the // top articles by in-degree, unless a mainTag is picked instead. See 0081's "Solution" §2. export const DEFAULT_TOP_N = 120; // How many of the most-connected *visible* nodes keep a permanently drawn label — the rest // only label on hover, per the spec's "pas 248 labels" rule. export const PERMANENT_LABEL_COUNT = 8; // Nodes are drawn as the site's own mascot (see ./meerkats for which sticker) rather than as // flat-colored dots — but only where the sticker still says something. Measured on the // rendered sizes: at a 16px diameter the meerkat is recognizable, at 12px it is a warm blob // with a colored ring, and below that the 1.5px ring eats a third of it and a flat dot reads // better. In the default view every node clears this at any desktop width (drawn radii run // 6-18px); the leftovers are the marginal satellites of a filtered view. export const MEERKAT_MIN_DRAWN_RADIUS = 6; // A node with no visible connection sits at MIN_NODE_RADIUS (a few px) — under the threshold // above, so it would be the one node the reader's eye lands on and the only one still a dot. // Only those get this floor; everything else keeps its data-driven size, which is what the // build-time collision layout was computed against. export const MEERKAT_MIN_RADIUS = 13; /** One article, as shipped by `plugins/blog-graph-plugin` (build-time layout already computed). */ export interface BlogGraphNode { slug: string; title: string; permalink: string; date: string; mainTag: string | null; series: string | null; readingTime: number; inDegree: number; radius: number; color: string; x: number; y: number; } export type BlogGraphEdgeType = "link" | "series" | "tag"; export interface BlogGraphEdge { source: string; target: string; type: BlogGraphEdgeType; weight: number; } export interface BlogGraphData { width: number; height: number; nodes: BlogGraphNode[]; edges: BlogGraphEdge[]; meta: { articleCount: number; seriesCount: number; linkCount: number; }; } /** Maps the fixed virtual coordinate space onto the actual canvas pixel size. */ export interface Transform { scale: number; offsetX: number; offsetY: number; } /** * The nodes to draw: either every node under the chosen mainTag, or — by default — the top * `topN` articles by in-degree across the whole corpus. Never the full corpus at once. */ export function selectVisibleNodes( nodes: BlogGraphNode[], { mainTag, topN = DEFAULT_TOP_N }: { mainTag?: string | null; topN?: number } = {}, ): BlogGraphNode[] { if (mainTag) { return nodes.filter((node) => node.mainTag === mainTag); } return [...nodes].sort((a, b) => b.inDegree - a.inDegree).slice(0, topN); } /** Edges whose two endpoints are both currently visible. */ export function selectVisibleEdges( edges: BlogGraphEdge[], visiblePermalinks: Set, ): BlogGraphEdge[] { return edges.filter( (edge) => visiblePermalinks.has(edge.source) && visiblePermalinks.has(edge.target), ); } /** * Maps the (fixed, build-time) virtual coordinate space onto the actual canvas size, * fitting the *visible* nodes' bounding box so a single mainTag's cluster — which only * occupies a fraction of the full layout — isn't drawn tiny in a mostly-empty canvas. */ export function fitTransform( nodes: BlogGraphNode[], canvasWidth: number, canvasHeight: number, padding = 40, ): Transform { if (nodes.length === 0) { return { scale: 1, offsetX: 0, offsetY: 0 }; } let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const node of nodes) { minX = Math.min(minX, node.x - node.radius); maxX = Math.max(maxX, node.x + node.radius); minY = Math.min(minY, node.y - node.radius); maxY = Math.max(maxY, node.y + node.radius); } const spanX = Math.max(maxX - minX, 1); const spanY = Math.max(maxY - minY, 1); const availableWidth = Math.max(canvasWidth - padding * 2, 1); const availableHeight = Math.max(canvasHeight - padding * 2, 1); const scale = Math.min(availableWidth / spanX, availableHeight / spanY, 4); const drawnWidth = spanX * scale; const drawnHeight = spanY * scale; const offsetX = (canvasWidth - drawnWidth) / 2 - minX * scale; const offsetY = (canvasHeight - drawnHeight) / 2 - minY * scale; return { scale, offsetX, offsetY }; } /** * The height/width ratio of the visible nodes' own bounding box, in the fixed virtual * coordinate space (so it's independent of the actual canvas pixel size). `null` when there * aren't enough nodes to infer a meaningful shape (0 or 1). */ function contentAspectRatio(nodes: BlogGraphNode[]): number | null { if (nodes.length < 2) { return null; } let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const node of nodes) { minX = Math.min(minX, node.x - node.radius); maxX = Math.max(maxX, node.x + node.radius); minY = Math.min(minY, node.y - node.radius); maxY = Math.max(maxY, node.y + node.radius); } const spanX = Math.max(maxX - minX, 1); const spanY = Math.max(maxY - minY, 1); return spanY / spanX; } /** * The canvas element's own height for the current container width and visible node set. * Blends two independent estimates and keeps whichever is *smaller*, because either one * alone has a blind spot that reproduces the "gigantic canvas for two elements" bug: * * - **aspect-ratio** fits the visible nodes' own bounding-box shape — works well when a * filtered mainTag's articles happen to cluster together spatially, but a couple of nodes * that are simply far apart *in the shared, whole-corpus layout* (they don't need to link * to each other to share a mainTag) produce an extreme ratio that clamps right back up to * the default max height, i.e. no visible shrink at all. * - **density** scales down purely from how few nodes are visible relative to the default * top-N view — catches exactly that case, but on its own would happily give a tall, tightly * clustered pair of nodes a squashed canvas it didn't need. * * Taking the minimum means neither blind spot alone can produce an oversized canvas. */ export function computeCanvasHeight( nodes: BlogGraphNode[], containerWidth: number, { defaultTopN = DEFAULT_TOP_N, minHeight = 220, maxRatio = 0.62, padding = 80, }: { defaultTopN?: number; minHeight?: number; maxRatio?: number; padding?: number; } = {}, ): number { if (containerWidth <= 0) { return 0; } const maxHeight = Math.round(containerWidth * maxRatio); const clamp = (height: number) => Math.min(Math.max(height, minHeight), maxHeight); const ratio = contentAspectRatio(nodes) ?? maxRatio; const aspectHeight = clamp(Math.round(containerWidth * ratio) + padding); const density = Math.min(nodes.length / defaultTopN, 1); const densityHeight = clamp( Math.round(minHeight + (maxHeight - minHeight) * Math.sqrt(density)), ); return Math.min(aspectHeight, densityHeight); } /** Applies a fitTransform to a single point. */ export function toCanvasSpace( node: BlogGraphNode, transform: Transform, ): { x: number; y: number } { return { x: node.x * transform.scale + transform.offsetX, y: node.y * transform.scale + transform.offsetY, }; } /** Permalinks of a node's direct neighbors, across every visible edge. */ export function neighborsOf( permalink: string, visibleEdges: BlogGraphEdge[], ): Set { const neighbors = new Set(); for (const edge of visibleEdges) { if (edge.source === permalink) neighbors.add(edge.target); else if (edge.target === permalink) neighbors.add(edge.source); } return neighbors; } /** Degree (any edge type) of each node, counted only from the currently visible edges. */ export function computeVisibleDegree( nodes: BlogGraphNode[], edges: BlogGraphEdge[], ): Map { const degree = new Map(nodes.map((node) => [node.permalink, 0])); for (const edge of edges) { if (degree.has(edge.source)) degree.set(edge.source, degree.get(edge.source)! + 1); if (degree.has(edge.target)) degree.set(edge.target, degree.get(edge.target)! + 1); } return degree; } /** * The visible nodes with no visible connection at all — degree zero in the *currently drawn* * edge set, which is not the same as `inDegree === 0` (a post can be linked to by an article * the top-N cutoff or the mainTag filter left out). They are the only nodes whose drawn size * is not purely data-driven; see MEERKAT_MIN_RADIUS. */ export function pickOrphanNodes( nodes: BlogGraphNode[], edges: BlogGraphEdge[], ): Set { const degree = computeVisibleDegree(nodes, edges); return new Set( nodes .filter((node) => (degree.get(node.permalink) ?? 0) === 0) .map((node) => node.permalink), ); } /** The on-canvas radius for a node, floored for orphans only (see MEERKAT_MIN_RADIUS). */ export function displayRadius( node: BlogGraphNode, transform: Transform, isOrphan: boolean, ): number { const base = node.radius * transform.scale; return isOrphan ? Math.max(base, MEERKAT_MIN_RADIUS) : base; } /** Groups posts by mainTag for the no-JS / mobile fallback list, each group sorted by date desc. */ export function groupByMainTag(nodes: BlogGraphNode[]): [string, BlogGraphNode[]][] { const groups = new Map(); for (const node of nodes) { const key = node.mainTag ?? "other"; if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(node); } for (const group of groups.values()) { group.sort((a, b) => b.date.localeCompare(a.date)); } return [...groups.entries()].sort((a, b) => b[1].length - a[1].length); } ``` **src/components/BlogGraph/GroupedList.tsx** ```typescript /** * The graph's fallback rendering — a plain list of every article grouped by mainTag. * * Used in two situations, per 0081's accessibility requirements: * - Always server-rendered, so the page stays indexable and useful with JavaScript * disabled (the canvas graph is a progressive enhancement on top of this, not a * replacement for it). * - Kept as the *only* rendering on narrow viewports, where a force-directed graph has no * room to be legible (BlogGraph swaps to this below the mobile breakpoint). */ import Link from "@docusaurus/Link"; import Translate from "@docusaurus/Translate"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import { groupByMainTag, type BlogGraphNode } from "./utils"; import styles from "./styles.module.css"; interface Props { nodes: BlogGraphNode[]; } export default function GroupedList({ nodes }: Props) { const groups = groupByMainTag(nodes); const tagLabel = useTagLabel(); return (
{groups.map(([mainTag, posts]) => (

{/* "other" is `groupByMainTag`'s bucket for articles with no mainTag — it is not a tags.yml key, so it has no label to look up and needs its own UI string. */} {mainTag === "other" ? ( Other ) : ( tagLabel(mainTag) )}{" "} ({posts.length})

    {posts.map((post) => (
  • {post.title}
  • ))}
))}
); } ``` **src/components/BlogGraph/styles.module.css** ```css .wrap { margin: 1.5rem 0; } .intro { color: var(--mk-ink-soft); margin-bottom: 1rem; } .controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem; } .selectLabel { composes: mk-eyebrow from global; display: flex; flex-direction: column; gap: 0.3rem; } .select { padding: 0.45rem 0.6rem; font-family: inherit; font-size: 0.95rem; letter-spacing: 0; text-transform: none; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); background-color: var(--mk-surface); color: var(--mk-ink); min-width: 260px; } .select:focus { outline: none; border-color: var(--mk-primary); box-shadow: 0 0 0 3px var(--mk-key-bg); } .counter { margin: 0; color: var(--mk-ink-soft); font-size: 0.9rem; white-space: nowrap; font-variant-numeric: tabular-nums; } /* * NOTE for whoever tokenises index.tsx: it reads * --ifm-color-emphasis-500 from getComputedStyle for the canvas edge colour, * with a "#999999" fallback. That variable is remapped now, so the edges * follow the palette on their own — but the cold-grey fallback is still there * and should become #a2917a (--mk-n-500) for the case where the read fails. */ .canvas { display: block; width: 100%; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); background-color: var(--mk-surface); } .listFallback { margin-top: 1rem; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); padding: 0.5rem 0.9rem; } .listFallback summary { cursor: pointer; color: var(--mk-primary); font-weight: 600; padding: 0.4rem 0; transition: color 0.15s ease; } .listFallback summary:hover { color: var(--ifm-color-primary-dark); } .listFallback[open] summary { margin-bottom: 0.5rem; } .groupedList { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1.25rem 2rem; } .group { min-width: 0; } .groupTitle { font-family: var(--mk-font-display); font-size: 1rem; margin: 0 0 0.4rem; color: var(--mk-primary); } .groupCount { color: var(--mk-ink-soft); font-weight: 400; font-size: 0.85rem; font-variant-numeric: tabular-nums; } .groupItems { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; } .groupItems li { font-size: 0.9rem; line-height: 1.35; } @media (max-width: 768px) { .controls { flex-direction: column; align-items: stretch; } .select { min-width: 0; width: 100%; } .counter { white-space: normal; } } @media (prefers-reduced-motion: reduce) { .listFallback summary { transition: none; } } ``` Canvas, not SVG, and that is not a stylistic preference: 262 nodes plus a thousand edges as DOM elements makes every hover a layout recalculation over a thousand nodes. On a canvas, a hover is one full redraw of a few hundred shapes, which is nothing. Register the plugin in `docusaurus.config.js` and give it a page: ```javascript title="docusaurus.config.js" plugins: [ "./plugins/blog-graph-plugin/index.mjs", // ... ], ``` ```mdx title="src/pages/map.mdx" --- title: "Blog Map" description: "An interactive map of the whole blog." hide_table_of_contents: true --- import BlogGraph from "@site/src/components/BlogGraph"; ``` ## The Three Kinds of Connection An edge means "these two posts are related", but relatedness comes in strengths, and mixing them at equal weight produces a hairball. My corpus emits three types, each with its own pull on the layout: | Type | What it means | Count | Layout pull | | -------- | -------------------------------------------------- | ----- | ---------------------------------------- | | `link` | One post's prose genuinely links to another | 680 | Strongest — distance 55, strength 0.85 | | `series` | Two consecutive posts (by date) in the same series | 144 | Medium — distance 70, strength 0.5 | | `tag` | Two posts sharing several tags | 202 | Weakest — distance 110, scaled by weight | The `tag` row is where the interesting failure lives. At this corpus size **almost every pair of posts shares at least one tag** — connecting them all would connect everything to everything and destroy both the layout and the picture. Raising the bar to two shared tags still leaves 1,355 pairs, of which 1,153 are the weakest possible kind: ```plaintext shared tags: 2 3 4 5 pairs: 1153 159 40 3 ``` So the plugin does two different things with that set. The **layout** uses every pair down to two shared tags, because that weak spatial pull is exactly what clusters "all my Docker posts" together. The **payload** ships only pairs with three or more, because nothing ever draws the rest — shipping the weight-2 majority would have tripled the JSON for invisible lines. Same data, two thresholds, one for the physics and one for the wire. ## Under the Hood (skip this if you just want the map) Four problems that were not obvious until the page was actually on screen. ### A two-node filter got a giant, empty canvas Pick a niche topic from the filter and you might get three articles. The canvas was still sized for 120. My first fix was to size it from the aspect ratio of the visible nodes' bounding box — which failed, because two articles sharing a `mainTag` do not have to sit near each other in the shared, whole-corpus layout. Two nodes at opposite ends produce an extreme ratio that clamps straight back to the maximum height. The second signal, node count relative to the default view, catches that case but would squash a genuinely tall, tight cluster. So `computeCanvasHeight()` computes both and keeps **whichever is smaller**. Neither blind spot can produce an oversized canvas on its own: ```typescript title="src/components/BlogGraph/utils.ts" const ratio = contentAspectRatio(nodes) ?? maxRatio; const aspectHeight = clamp(Math.round(containerWidth * ratio) + padding); const density = Math.min(nodes.length / defaultTopN, 1); const densityHeight = clamp( Math.round(minHeight + (maxHeight - minHeight) * Math.sqrt(density)), ); return Math.min(aspectHeight, densityHeight); ``` ### Labels stacking on top of each other Drawing 262 titles is unreadable, so only the eight most-connected visible nodes keep a permanent label, plus the hovered node and its direct neighbors. Even that stacks when a filtered topic is crowded. `selectNonOverlappingLabels()` walks the candidates in priority order — hovered node first, then neighbors, then hubs — measures each one with `ctx.measureText()` and drops any label whose box would intersect one already placed. Lower-priority labels lose; the hovered one always wins. ### Canvas cannot hear a theme change The node and edge colors are read from the site's own CSS variables so nothing is hardcoded — but a canvas is a bitmap, and toggling dark mode repaints nothing. The fix is a `MutationObserver` on ``'s `data-theme` attribute that bumps a counter, which is in the redraw effect's dependency array: ```typescript title="src/components/BlogGraph/index.tsx" useEffect(() => { const observer = new MutationObserver(() => setThemeVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); return () => observer.disconnect(); }, []); ``` ### The meerkats This blog has a mascot — it hides in the [page source](/blog/docusaurus-ascii-art), rides the [scroll-to-top button](/blog/docusaurus-go-top), and shows up on the 404 page. On the map it is not a garnish on a few nodes: **every bubble is a meerkat**, wearing the sticker its `mainTag` earned it. PHP articles hold a small purple elephant, Bash articles wear a hoodie, `code-quality` comes with a green checkmark. Around thirty tags have an explicit sticker and the rest draw one deterministically from whatever is left, so a topic always looks the same and its articles read as a family across the canvas. What makes this work is the images, not the code: they are square, face-centered portraits. The full-body poses I started with lost their head to a 20px circular crop, so they had to be picked and cropped one by one; a centered portrait survives a plain "cover" draw with no per-image tuning. Two rules keep it from turning into mush: * **a sticker needs room.** Below a 12px diameter the ring eats a third of the image and a flat colored dot says more — so that is what those nodes keep. * **an orphan is drawn bigger.** A node with **zero visible connections** sits at the minimum radius, which is exactly under that threshold. It gets a floor of 13px instead, so the one article nothing links to is the one you cannot miss. The ring around each bubble keeps the article's own accent color, so nothing of the series/tag signal is lost to the illustration. ## Conclusion The map did exactly what I hoped: five minutes after it first rendered, I had a list of posts that nothing linked to, and I could see that one series had drifted away from everything else. That is a maintenance tool disguised as a pretty picture. The part worth stealing, though, is not the graph — it is where the work happens. Every hard requirement on that page (no JavaScript, no motion, indexable, small payload) dissolved the moment the simulation moved from the browser to `loadContent()`. Docusaurus plugins run in Node with the whole corpus in hand and a build step nobody is waiting on; that is a lot of room to compute things properly, once, instead of asking every visitor's laptop to do it again. Next time you are about to reach for a client-side library, check whether the answer it computes could have been a constant in your build output. --- # Let Readers Flag Issues — A Multi-Type Feedback Widget for Docusaurus > Build a secure, SSR-safe reader-feedback widget for your Docusaurus blog — text selection, 4 feedback types, PHP API with HMAC nonce, rate limiting, deduplication, admin dashboard, and email notifications. --- ![Let Readers Flag Issues — A Multi-Type Feedback Widget for Docusaurus](/img/v2/docusaurus_typos.webp) **TL;DR** A reader spots a problem in your article — a typo, a factual error, an outdated command. They have no way to tell you. They move on. You stay unaware. This article builds a complete, one-way feedback widget from scratch: readers select a passage, a popover appears with four issue types (typo, incorrect info, outdated content, suggestion), they optionally add details, and the report lands in a JSON file on your server with an email notification. The PHP API is hardened with HMAC nonces, honeypot, rate limiting, and deduplication. The React component is SSR-safe, uses a clean state machine, and rate-limits submissions in `localStorage`. A companion admin dashboard at `/typo-dashboard` lets you review all reports by type. No database, no third-party service, no npm dependencies beyond what Docusaurus already ships. Your blog is your public face. A stray typo, a command that no longer works, an API that changed — these cost you credibility every time someone hits that paragraph. The problem is that most readers will not bother opening a GitHub issue or sending an email. But if you make it trivially easy — one click, right where they are — a surprising number will. This article builds that mechanism end to end. **Quick Jump:** [See it in action](#see-it-in-action) | [Create the component](#create-the-component) ## See it in action The widget is live on this very page. **Select a few words of this paragraph** with your mouse (or your finger on a phone): a popover appears right below the selection, offering four kinds of feedback. > Screenshot — https\://www\.avonture.be/blog/docusaurus-typo-report-component > > ![The feedback popover, with its four types, below a text selection](./images/typo_popover.webp) Pick one, and a small form lets the reader add an optional comment before sending. The form says it plainly: **this is a one-way signal** — the reader leaves no email, so nobody waits for a reply. > Screenshot — https\://www\.avonture.be/blog/docusaurus-typo-report-component > > ![The details form, shown once a feedback type has been chosen](./images/typo_form.webp) On your side, every report lands in a JSON file on the server, you get an email, and a token-protected page lists everything, newest first, with counters per type: > Screenshot — https\://www\.avonture.be/typo-dashboard > > ![The admin dashboard listing the reports received](./images/typo_dashboard.webp) ## How it works The feature has two independent halves that mirror the pattern used by the [Reaction component](/blog/docusaurus-reactions) (like / dislike votes): ```plaintext Reader selects text └─ mouseup / touchend fires on
└─ popover shows 4 feedback-type buttons (phase: selecting) └─ reader picks a type → optional details form (phase: confirming) └─ POST api/typo.php └─ validates nonce, honeypot, rate limits, dedup └─ writes typo-data.json └─ emails admin (6h cooldown) ``` * **No database** — reports are appended to a JSON file, under a file lock. * **No third-party service** — a single PHP script sits next to your static files. * **Defense in depth** — nine independent layers stop bots, floods and duplicates (detailed at the end, for the curious). * **SSR-safe** — the component renders nothing until the reader selects text. *** ## Part 1 — The PHP API ### Why PHP? Docusaurus produces a static site. It has no server-side runtime. If you host on shared hosting (OVH, Infomaniak, o2switch…), a small [PHP script](/blog/php-api-tips) sitting next to your static files is the lowest-friction backend you can have — no Node process to manage, no Docker container, no serverless cold starts. ### File structure on the server ```tree expanded=true showJSX=false debug=false title="File structure" public_html/ api/ .env ← secrets (never committed) .env.example ← template committed to git .htaccess ← protects JSON files typo.php ← the API typo-data.json ← reports (writable by web server) typo-ratelimit.json ← counters typo-notifications.json ← email throttle ``` ### The script The API answers three requests: `GET` hands out a nonce (and, with `?admin=TOKEN`, the full report list), `POST` stores a report, `OPTIONS` answers the CORS preflight. Each `POST` goes through the nine layers described in the security section at the end of this article before anything is written. **api/typo.php** ```php $message]); exit; } // ── Nonce ───────────────────────────────────────────────────────────────────── function makeNonce(int $window): string { return hash_hmac('sha256', $window . '|typo-report', NONCE_SECRET); } function validateNonce(string $candidate): bool { $now = (int) floor(time() / NONCE_COOLDOWN_SECONDS); return hash_equals(makeNonce($now), $candidate) || hash_equals(makeNonce($now - 1), $candidate); } // ── Input validation ────────────────────────────────────────────────────────── function validateAndSanitize(array $body): array { if (($body['website'] ?? '') !== '') return []; // honeypot $slug = sanitizeSlug($body['slug'] ?? ''); if ($slug === '') jsonError(400, 'Missing slug'); $text = trim($body['text'] ?? ''); if (mb_strlen($text) < 3 || mb_strlen($text) > 150 || !preg_match('/^\P{C}+$/u', $text)) jsonError(400, 'Invalid text'); $allowedTypes = ['typo', 'incorrect', 'outdated', 'suggestion']; $type = trim($body['type'] ?? ''); if (!in_array($type, $allowedTypes, true)) jsonError(400, 'Invalid type'); $comment = trim($body['comment'] ?? ''); if ($comment !== '' && (mb_strlen($comment) > 300 || !preg_match('/^\P{C}+$/u', $comment))) jsonError(400, 'Invalid comment'); $context = mb_substr(trim($body['context'] ?? ''), 0, 300); return compact('slug', 'text', 'type', 'comment', 'context'); } // ── Rate limiting ───────────────────────────────────────────────────────────── function checkRateLimits(string $slug): void { $file = __DIR__ . '/typo-ratelimit.json'; $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $ipKey = hash('sha256', IP_HASH_SALT . $ip); $now = time(); $fp = fopen($file, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { if ($fp) fclose($fp); return; } $raw = stream_get_contents($fp); $data = $raw !== '' ? (json_decode($raw, true) ?: []) : []; $g = $data['__global__'] ?? ['window_start' => $now, 'count' => 0]; if ($now - $g['window_start'] > GLOBAL_WINDOW) $g = ['window_start' => $now, 'count' => 0]; if (++$g['count'] > GLOBAL_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); } $data['__global__'] = $g; $ipData = $data[$ipKey] ?? ['hourly' => [], 'articles' => []]; $ipData['hourly'] = array_values(array_filter($ipData['hourly'], fn($ts) => $ts > $now - 3600)); if (count($ipData['hourly']) >= IP_HOURLY_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); } $artKey = $ipKey . '|' . $slug; $artData = array_values(array_filter($data[$artKey] ?? [], fn($ts) => $ts > $now - 86400)); if (count($artData) >= IP_ARTICLE_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); } $ipData['hourly'][] = $now; $artData[] = $now; $data[$ipKey] = $ipData; $data[$artKey] = $artData; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); flock($fp, LOCK_UN); fclose($fp); } // ── Dedup + store ───────────────────────────────────────────────────────────── function isDuplicate(string $slug, string $text): bool { $hash = hash('sha256', $slug . '|' . mb_strtolower($text)); foreach (loadData(__DIR__ . '/typo-data.json')[$slug] ?? [] as $r) { if (($r['text_hash'] ?? '') === $hash) return true; } return false; } function storeReport(array $fields): void { ['slug' => $slug, 'text' => $text, 'type' => $type, 'comment' => $comment, 'context' => $context] = $fields; $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $fp = fopen(__DIR__ . '/typo-data.json', 'c+'); if (!$fp) return; if (flock($fp, LOCK_EX)) { $raw = stream_get_contents($fp); $data = $raw !== '' ? (json_decode($raw, true) ?: []) : []; $data[$slug][] = [ 'id' => bin2hex(random_bytes(6)), 'type' => $type, 'text' => $text, 'text_hash' => hash('sha256', $slug . '|' . mb_strtolower($text)), 'context' => $context, 'comment' => $comment, 'ts' => time(), 'ip_hash' => hash('sha256', IP_HASH_SALT . $ip), ]; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); flock($fp, LOCK_UN); } fclose($fp); } function maybeNotifyTypo(string $slug, string $text, string $type): void { $file = __DIR__ . '/typo-notifications.json'; $throttle = loadData($file); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) return; $typeLabels = [ 'typo' => '🔤 Typo', 'incorrect' => '❌ Incorrect info', 'outdated' => '⏰ Outdated content', 'suggestion' => '💡 Suggestion', ]; $typeLabel = $typeLabels[$type] ?? $type; $articleUrl = SITE_URL . '/' . $slug; $subject = "[Blog] Feedback ($type) on: $slug"; $body = implode("\n", [ "A reader flagged an issue.", "", "Article : $articleUrl", "Type : $typeLabel", "Text : $text", "", "Review all reports: GET " . SITE_URL . "/api/typo.php?admin=TOKEN", ]); $headers = "From: noreply@yourdomain.com\r\nContent-Type: text/plain; charset=utf-8"; if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($file, $throttle); } } // ── Router ──────────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; if ($method === 'GET') { if (array_key_exists('nonce', $_GET)) { echo json_encode(['nonce' => makeNonce((int) floor(time() / NONCE_COOLDOWN_SECONDS))]); exit; } if (array_key_exists('admin', $_GET)) { if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) jsonError(403, 'Forbidden'); echo json_encode(loadData(__DIR__ . '/typo-data.json')); exit; } jsonError(400, 'Missing parameter'); } if ($method !== 'POST') jsonError(405, 'Method not allowed'); $body = json_decode(file_get_contents('php://input'), true) ?? []; $nonce = $body['nonce'] ?? ''; if ($nonce === '' || !validateNonce($nonce)) jsonError(403, 'Invalid nonce'); $fields = validateAndSanitize($body); if ($fields === []) { echo json_encode(['ok' => true]); exit; } ['slug' => $slug, 'text' => $text] = $fields; checkRateLimits($slug); if (isDuplicate($slug, $text)) { echo json_encode(['ok' => true]); exit; } storeReport($fields); maybeNotifyTypo($slug, $text, $fields['type']); echo json_encode(['ok' => true]); ``` ### Protect the JSON files Without the [.htaccess rule](/blog/apache-htaccess) shipped in `api/.htaccess`, anyone can `GET https://yourdomain.com/api/typo-data.json` and read every report. ### Secrets The `api/.env` file holds two secrets. Generate them with: ```bash openssl rand -hex 32 # IP_HASH_SALT openssl rand -hex 32 # NONCE_SECRET ``` *** ## Part 2 — The React component ### SSR safety first Docusaurus renders pages server-side (Node.js) before hydrating them in the browser. Any code that touches `window`, `document`, or `localStorage` will crash the SSR pass if it runs at the module level or inside the component body. The rules: 1. All DOM access goes inside `useEffect` — it only runs in the browser. 2. State is initialized to `"idle"`, not a value read from the browser. 3. The component returns `null` when idle — no DOM, no SSR mismatch. ### State machine Six phases, linear with one fork: ```plaintext idle └─ user selects text → selecting └─ user picks a feedback type → confirming └─ form submitted → submitting ├─ success → done └─ error → error ``` `done` and `error` show a dismissible badge that resets to `idle`. `selecting` and `confirming` reset to `idle` if the user clicks outside the popover. `confirming` has a "change" link that goes back to `selecting`. ### localStorage helpers Before the type-selection popover appears, two client-side guards run: 1. **Rate limit** — max 5 submissions per browser per hour. Timestamps are stored as a JSON array in `localStorage` under `"typo_reports"`. Entries older than 24 hours are pruned on every write. 2. **Deduplication** — an FNV-1a hash of `slug + "|" + lowercase(text)` is stored alongside the timestamp. If the same text was already reported from this browser (regardless of type), the component skips straight to `done`. FNV-1a is used instead of `crypto.subtle.digest` because it is synchronous, and the collision risk is acceptable for this use case. ```js function fnv1a(str) { let hash = 0x811c9dc5; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash.toString(16); } ``` ### Feedback types Four types cover the vast majority of issues readers might notice: ```js const FEEDBACK_TYPES = [ { id: "typo", icon: "🔤", label: "Typo" }, { id: "incorrect", icon: "❌", label: "Incorrect" }, { id: "outdated", icon: "⏰", label: "Outdated" }, { id: "suggestion", icon: "💡", label: "Suggestion" }, ]; ``` They are displayed in a 2×2 grid. The PHP API validates the `type` field against this same whitelist — any other value gets a 400. ### Capturing context When the user selects text, the component records ±100 characters of surrounding text from `article.innerText`. This context is sent to the API so you can locate the exact sentence without opening the post: ```js const fullText = article.innerText || ""; const idx = fullText.indexOf(text); contextRef.current = idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : ""; ``` ### Tooltip positioning The popover appears just below the selection. `window.scrollY + rect.bottom + 8` puts it 8px below the selected text's bottom edge. To prevent the 280px-wide form from overflowing the right edge of the viewport: ```js const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296); ``` ### The component **src/components/TypoReport/index.tsx** ```typescript import { useState, useEffect, useRef, useCallback, type FormEvent, type JSX, } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./styles.module.css"; const STORAGE_KEY = "typo_reports"; // [{ts, hash}] const MAX_PER_HOUR = 5; const FEEDBACK_TYPES = [ { id: "typo", icon: "🔤", label: "Typo" }, { id: "incorrect", icon: "❌", label: "Incorrect" }, { id: "outdated", icon: "⏰", label: "Outdated" }, { id: "suggestion", icon: "💡", label: "Suggestion" }, ]; interface StoredReport { ts: number; hash: string; } // FNV-1a 32-bit hash (no crypto needed for client-side dedup) function fnv1a(str: string): string { let hash = 0x811c9dc5; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash.toString(16); } function getStored(): StoredReport[] { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); } catch { return []; } } function isLocalRateLimited() { const now = Date.now(); return getStored().filter((r) => r.ts > now - 3_600_000).length >= MAX_PER_HOUR; } function isLocalDuplicate(slug: string, text: string) { const hash = fnv1a(slug + "|" + text.toLowerCase()); return getStored().some((r) => r.hash === hash); } function recordLocalSubmission(slug: string, text: string) { const now = Date.now(); const hash = fnv1a(slug + "|" + text.toLowerCase()); const pruned = getStored().filter((r) => r.ts > now - 86_400_000); try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...pruned, { ts: now, hash }])); } catch {} } interface Props { metadata?: { permalink?: string; }; } type Phase = "idle" | "selecting" | "confirming" | "submitting" | "done" | "error"; export default function TypoReport({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/typo.php`; // State machine: idle → selecting → confirming → submitting → done | error const [phase, setPhase] = useState("idle"); const [selectedText, setSelectedText] = useState(""); const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 }); const [feedbackType, setFeedbackType] = useState(""); const [comment, setComment] = useState(""); const nonceRef = useRef(null); const articleRef = useRef(null); const wrapperRef = useRef(null); const contextRef = useRef(""); // ±100 chars around selection // Fetch nonce + wire up DOM listeners on mount. useEffect(() => { if (!slug) return; fetch(`${apiUrl}?nonce`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data?.nonce) nonceRef.current = data.nonce; }) .catch(() => {}); const article = document.querySelector("article") || document.querySelector(".theme-doc-markdown"); if (!article) return; articleRef.current = article; // Arrow-assigned, not a `function` declaration: that lets TS carry the // `if (!article) return` narrowing above into this closure, so `article` is // non-null on lines below without a `!` assertion. const handleSelection = () => { const sel = window.getSelection(); if (!sel || sel.isCollapsed) return; const text = sel.toString().trim(); if (text.length < 3) return; const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect(); if (!article.contains(range.commonAncestorContainer)) return; const fullText = article.innerText || ""; const idx = fullText.indexOf(text); contextRef.current = idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : ""; const top = window.scrollY + rect.bottom + 8; const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296); setSelectedText(text); setTooltipPos({ top, left }); setPhase("selecting"); }; function handleMouseDown(e: MouseEvent) { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { setPhase("idle"); } } article.addEventListener("mouseup", handleSelection); article.addEventListener("touchend", handleSelection); document.addEventListener("mousedown", handleMouseDown); return () => { article.removeEventListener("mouseup", handleSelection); article.removeEventListener("touchend", handleSelection); document.removeEventListener("mousedown", handleMouseDown); }; }, [slug, apiUrl]); const handleTypeSelect = useCallback( (type: string) => { if (isLocalRateLimited()) { setPhase("error"); return; } if (isLocalDuplicate(slug, selectedText)) { setPhase("done"); return; } setFeedbackType(type); setComment(""); setPhase("confirming"); }, [slug, selectedText], ); const handleSubmit = useCallback( async (e: FormEvent) => { e.preventDefault(); setPhase("submitting"); let nonce = nonceRef.current; if (!nonce) { try { const r = await fetch(`${apiUrl}?nonce`); const d = r.ok ? await r.json() : null; nonce = d?.nonce ?? null; nonceRef.current = nonce; } catch {} } const honeypot = (e.currentTarget.elements.namedItem("website") as HTMLInputElement | null) ?.value ?? ""; try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, text: selectedText, type: feedbackType, comment, context: contextRef.current, website: honeypot, nonce: nonce ?? "", }), }); if (!res.ok) { setPhase("error"); return; } recordLocalSubmission(slug, selectedText); setPhase("done"); } catch { setPhase("error"); } }, [slug, selectedText, feedbackType, comment, apiUrl], ); const handleBack = useCallback(() => setPhase("selecting"), []); const handleCancel = useCallback(() => setPhase("idle"), []); const handleDismiss = useCallback(() => setPhase("idle"), []); // SSR-safe: render nothing when idle. if (phase === "idle") return null; const typeInfo = FEEDBACK_TYPES.find((t) => t.id === feedbackType); return (
{phase === "selecting" && (

What kind of issue?

{FEEDBACK_TYPES.map(({ id, icon, label }) => ( ))}
)} {phase === "confirming" && (
" {selectedText.length > 80 ? selectedText.slice(0, 80) + "…" : selectedText} "
{typeInfo && (
{typeInfo.icon} {typeInfo.label}
)}