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

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

```
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 `` 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 (``) 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 `` or absolute (``) 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.
---

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

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

Part 2 below goes further and adds a full `/series` page listing every series as cards:

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:"}
}
>
);
}
```
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 (
);
}
```
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 (
);
};
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;
```

### 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:

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.

## 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:

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

**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](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
>
> 
Up close, it's a round button holding whatever icon you hand it:

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

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

**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 (
<>
{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](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 (
);
}
```
**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
---

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

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

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

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

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

**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).

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

In the next chapters, we'll see how to improve our `rss.xml`. Once done, you'll get this:

And by opening an article, we'll get this:

## 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