Plugin Remark Replace Words
I developed this remark/MDX plugin to automatically replace certain words in .mdx/.md content with other text, icons, or even React components (e.g., Tooltip). It's ideal for keeping terminology consistent, adding contextual help, or "styling" recurring keywords.
The plugin walks Markdown/MDX text nodes, skips certain parents (links, headings, code…), merges all + the current section (blog/pages/docs), sorts keys by decreasing length, then replaces with plain text or MDX components. Matching is Unicode‑safe to avoid false positives inside words.
Combined with a Tooltip component, this plugin lets you build a real interactive glossary: authors write plain Markdown, and the plugin replaces terms with rich components (tooltips, icons, buttons) without extra effort.
Why this plugin?
- Standardize brand or team terms (e.g., product names)
- Automatically substitute words or components
- Centralize replacement rules in a simple per‑section JSON file (blog, pages, docs)
- Keep Markdown/MDX clean and readable, without intrusive tags or components
- Easier maintenance: a change in JSON propagates everywhere
- And why not?
Plugin code
File structure
Code
import { visit } from 'unist-util-visit';
import mapping from './replacements.json' with { type: "json" };
const DEBUG = true;
const stats = {};
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isForbiddenParent(parent, replacementComponentNames) {
if (!parent) return false;
if (
parent.type === 'link' ||
parent.type === 'linkReference' ||
parent.type === 'definition' ||
parent.type === 'code' ||
parent.type === 'inlineCode' ||
parent.type === 'heading' ||
parent.type === 'image' ||
parent.type === 'imageReference'
) {
return true;
}
if (
(parent.type === 'mdxJsxFlowElement' || parent.type === 'mdxJsxTextElement') && (
parent.name === 'a' ||
replacementComponentNames.has(parent.name) )
) {
return true;
}
return false;
}
export default function remarkReplaceFromJson() {
return (tree, file) => {
const filePath = (file.path || '').replace(/\\/g, '/');
let type = 'pages';
if (filePath.includes('/blog/')) type = 'blog';
else if (filePath.includes('/docs/')) type = 'docs';
else if (filePath.includes('/pages/')) type = 'pages';
const allMapping = mapping.all || {};
const typeMapping = mapping[type] || {};
const mergedMapping = { ...allMapping, ...typeMapping };
const replacementComponentNames = new Set(
Object.values(mergedMapping)
.map((c) => c.component)
.filter(Boolean)
);
const entries = Object.entries(mergedMapping).sort((a, b) => b[0].length - a[0].length);
visit(tree, 'text', (node, index, parent) => {
if (isForbiddenParent(parent, replacementComponentNames)) return;
if (!node.value || typeof node.value !== 'string') return;
node.value = node.value.normalize('NFC');
let fragments = [{ type: 'text', value: node.value }];
let replaced = false;
for (const [word, conf] of entries) {
if (!word) continue;
const safe = escapeRegex(word);
const regex = new RegExp(
`(?<![\\p{L}\\p{N}_/"'’-])${safe}(?!(?:[\\p{L}\\p{N}_/"'’-]|\\.[\\p{L}\\p{N}]))`,
'giu'
);
fragments = fragments.flatMap((frag) => {
if (frag.type !== 'text') return [frag];
if (!regex.test(frag.value)) return [frag];
regex.lastIndex = 0;
const parts = frag.value.split(regex);
const matches = frag.value.match(regex) || [];
if (!matches.length) return [frag];
replaced = true;
if (DEBUG) {
stats[filePath] = stats[filePath] || {};
stats[filePath][word] = (stats[filePath][word] || 0) + matches.length;
}
const newNodes = [];
parts.forEach((part, i) => {
if (part) newNodes.push({ type: 'text', value: part });
if (i < matches.length) {
const compName = conf.component;
const replacementLabel = conf.children || matches[i];
if (compName) {
const isInline =
parent.type === 'paragraph' ||
parent.type === 'mdxJsxFlowElement' ||
parent.type === 'emphasis' ||
parent.type === 'strong' ||
parent.type === 'delete' ||
parent.type === 'listItem';
const nodeType = isInline ? 'mdxJsxTextElement' : 'mdxJsxFlowElement';
newNodes.push({
type: nodeType,
name: compName,
attributes: Object.entries(conf.props || {}).map(([key, value]) => ({
type: 'mdxJsxAttribute',
name: key,
value,
})),
children: replacementLabel ? [{ type: 'text', value: replacementLabel }] : [],
});
} else {
newNodes.push({ type: 'text', value: replacementLabel });
}
}
});
return newNodes;
});
}
if (replaced) {
parent.children.splice(index, 1, ...fragments);
}
});
if (DEBUG && Object.keys(stats).length > 0 && !global.__remarkReplaceWordsReported) {
global.__remarkReplaceWordsReported = true;
setTimeout(() => {
global.__remarkReplaceWordsReported = false;
const typeTotals = { blog: {}, pages: {}, docs: {}, all: {} };
for (const [file, words] of Object.entries(stats)) {
let type = 'pages';
const normalized = file.replace(/\\/g, '/');
if (normalized.includes('/blog/')) type = 'blog';
else if (normalized.includes('/docs/')) type = 'docs';
else if (normalized.includes('/pages/')) type = 'pages';
for (const [word, count] of Object.entries(words)) {
if (mapping.all && Object.prototype.hasOwnProperty.call(mapping.all, word)) {
typeTotals.all[word] = (typeTotals.all[word] || 0) + count;
} else {
typeTotals[type][word] = (typeTotals[type][word] || 0) + count;
}
}
}
console.log("\n=== Rapport de remplacements Remark ===");
for (const type of ['blog', 'pages', 'docs', 'all']) {
const words = typeTotals[type];
if (Object.keys(words).length > 0) {
console.log(`[${type}]`);
for (const [word, count] of Object.entries(words)) {
console.log(`- \"${word}\" remplacé ${count} fois`);
}
}
}
console.log("======================================\n");
}, 5000);
}
};
}
JSON configuration
In your plugins/remark-replace-words folder, add a file: replacements.json.
Replacements are driven by plugins/remark-replace-words/replacements.json. It contains 4 top‑level keys:
all: global replacements (all sections)blog: replacements specific to blog postspages: replacements for pagesdocs: replacements for documentation
Each entry uses the word to detect as the key, and an object as the value:
component(optional): name of the MDX component to inject (e.g., Tooltip, LogoIcon)props(optional): props passed to the componentchildren(optional): component’s child text; if missing, the matched word is reused
{
"all": {
"Giti": {
"component": "Tooltip",
"props": { "text": "DocuxLab" },
"children": "Global replacement"
},
"doculab": {
"component": "Tooltip",
"props": { "text": "dokidoki" },
"children": "Global replacement"
}
},
"blog": {
"kiki": {
"component": "Tooltip",
"props": { "text": "Appears at the bottom", "position": "bottom", "model": "teacher" },
"children": "my new word"
},
"schema-diagram": {
"component": "ImageOnClick",
"props": {
"imageUrl": "/img/404bg.png",
"altText": "Architecture diagram",
"buttonName": "Click me"
}
},
"Cavo": {
"children": "grandpa Christophe"
},
"goatcounter": {
"component": "a",
"props": { "href":"https://www.goatcounter.com/" },
"children": "Goatcounter"
},
"cta-docs": {
"component": "DocusaurusButton",
"props": { "to": "/img/remarkreplace.png", "label": "Read Docs" },
"children": "Read the documentation"
},
"nana": {
"component": "LogoIcon",
"props": { "name": "logos:css-3", "size": "124" }
},
"roki": {
"component": "LogoIcon",
"props": { "name": "docusaurus", "size": "124" }
},
"node": {
"component": "LogoIcon",
"props": { "name": "logos:nodejs-icon", "size": 64 }
}
},
"pages": {
"nana": {
"component": "LogoIcon",
"props": { "name": "ccs-3", "size": "124" }
},
"roki": {
"component": "LogoIcon",
"props": { "name": "docusaurus", "size": "124" }
},
"node": {
"component": "LogoIcon",
"props": { "name": "logos:nodejs-icon", "size": 64 }
},
"schema-diagram": {
"component": "ImageOnClick",
"props": {
"imageUrl": "/img/DocuxLab.png",
"altText": "Architecture diagram",
"buttonName": "View diagram"
}
}
},
"docs": {
"kiki": {
"component": "Tooltip",
"props": { "text": "Documentation" },
"children": "Docux Docs"
}
}
}
Installation and setup
The plugin is already present in this repo under plugins/remark-replace-words. To enable it in Docusaurus, it’s referenced in docusaurus.config.js at the Blog, Pages, and Docs presets level.
// ...imports
import remarkReplaceWords from "./plugins/remark-replace-words/index.js"
export default {
// ...
presets: [
[
'classic',
({
blog: {
remarkPlugins: [
// ...
[remarkReplaceWords, "blog"],
],
},
pages: {
remarkPlugins: [
// ...
[remarkReplaceWords, "pages"],
],
},
// If you have a Docs section
docs: {
remarkPlugins: [
// ...
[remarkReplaceWords, "docs"],
],
},
})
]
]
}
The second argument ("blog" | "pages" | "docs") selects a specific section in the configuration JSON. An all block is also available for global replacements.
Strategy
- Section detection from the file path (
/blog/,/pages/,/docs/), otherwise fallback topages. - Mapping merge:
merged = { ...mapping.all, ...mapping[section] }to support global then section‑specific rules. - Unicode NFC normalization to stabilize accents (é, è, ê, ô, ç, œ, ï, …).
- Ignored parents to avoid unwanted replacements:
- Markdown/MDX:
link,linkReference,definition,code,inlineCode,heading,image,imageReference,blockquote,url. - MDX JSX:
atag and any component already inserted by a previous replacement (prevents re‑traversal).
- Markdown/MDX:
- Pre‑collect replacement component names (Set) to detect them during the visit and short‑circuit.
- Sort replacement keys by decreasing length to prevent overlaps (longest first).
- Robust Unicode word‑boundary regex to avoid matching inside a word and when adjacent to
_ - / " ' ’:(?<![\p{L}\p{N}_/"'’-])word(?![\p{L}\p{N}_/"'’-])withgiuflags.
- Replace by fragments: split the text and insert either plain text or an MDX element (
mdxJsxTextElementinline,mdxJsxFlowElementblock) withname,attributes(props) andchildren. childrenchoice: ifchildrenis defined in JSON, use it; otherwise reuse the captured value.- DEBUG report (optional): aggregate by type (
blog,pages,docs,all) and print once at the end of build/dev. - Performance goal: avoid replacements in undesired areas, reduce false positives with the Unicode regex, and limit re‑traversal via the Set and longest‑first sort.
Concrete replacement examples
Here are several use cases, based on this project’s JSON.
Word → Word (plain text)
- Key:
Cavoin theblogsection:In a post, typing"Cavo": { "children": "grandpa Christophe" }Rendered“Cavo” will render grandpa ChristopheCavo= grandpa Christophe
Word → Icon (LogoIcon)
-
Keys:
nanaandrokiin thepagessection:"nana": { "component": "LogoIcon", "props": { "name": "ccs-3", "size": "124" } }
"roki": { "component": "LogoIcon", "props": { "name": "docusaurus", "size": "124" } }On an MDX page, typing “nana” will display the CSS‑3 icon and “roki” the Docusaurus icon.Rendered: On an MDX page, typing “” will display the CSS‑3 icon and “” the Docusaurus icon.
Additional example with an explicit Iconify set:
"node": { "component": "LogoIcon", "props": { "name": "logos:nodejs", "size": 64 } }Rendered
node= icon of node.js technology
Word → Tooltip
- Keys:
Gitiandkiki:"Giti": { "component": "Tooltip", "props": { "text": "DocuxLab" }, "children": "Global replacement" }
"kiki": { "component": "Tooltip", "props": { "text": "Documentation" }, "children": "Docux Docs" }Rendered: The word “DocuxLab” anywhere will be replaced byThe word “Giti” anywhere will be replaced by `<Tooltip text="DocuxLab">Global replacement</Tooltip>`. In docs, “kiki” becomes `<Tooltip text="Documentation">Docux Docs</Tooltip>`.<Tooltip text="DocuxLab">Global replacement</Tooltip>. In docs, “Appears at the bottom” becomes<Tooltip text="Documentation">Docux Docs</Tooltip>.
Word → Clickable image (ImageOnClick)
ImageOnClick is a local component that displays an image overlay on click.
In a post, typing “schema-diagram” will display a clickable link opening the image `/img/diagram.png` fullscreen.
Replacement example:
{
"pages": {
"schema-diagram": {
"component": "ImageOnClick",
"props": {
"imageUrl": "/img/rocket_1f680.gif",
"altText": "Architecture diagram",
"buttonName": "Click me"
}
}
}
}
On a page, typing Click me will display a clickable link opening the image /img/rocket_1f680.gif fullscreen.
Important behaviors to know
- Ignored parents: no replacement inside links, headings, code blocks/inline code, images.
- Merged sections: for a blog file, merge
allthenblog(the latter wins on conflict). Same logic forpagesanddocs. - Replacement order: sort by decreasing word length to avoid overlaps.
- Unicode boundaries: the regex uses lookarounds and Unicode classes to avoid matching substrings (e.g., don’t replace “TS” inside “intérêts”).
- Build report: with DEBUG on, a console report per section shows how many times each word was replaced.
Why MDX/MD instead of JSX/TSX pages?
File compatibility: the plugin works in both
.mdand.mdxpages. Opinion: I chose to convert all my pages to.mdxto guarantee consistent support for MDX components and the plugin everywhere. Handling.jsxor.tsxpages would require a different, more complex approach with a dedicated React component for pages.
- Remark/rehype pipeline: Remark plugins only run on Markdown/MDX content. JSX/TSX pages are plain React components compiled by Babel/TS and bypass the Markdown pipeline, so the plugin never “sees” their text.
- AST expectations: This plugin operates on the Markdown AST (mdast) text nodes. In JSX, text is split across JSXText/StringLiteral and mixed with elements/props. Safe replacement there would require a Babel/SWC transform or a runtime React tree walk, with high risk of touching code, props, or links.
- Word-boundaries and Unicode: The plugin relies on contiguous text to apply robust Unicode word-boundary regexes. In JSX, sentences are often fragmented across nodes/components, making accurate matching and ordering (inline vs block) unreliable.
- MDX is a perfect fit: MDX gives a Markdown-first AST where narrative text lives in text nodes, and it officially supports injecting MDX components as replacements—exactly what the plugin generates.
If you must keep JSX/TSX pages, possible alternatives:
- Build-time: write a Babel/SWC plugin that transforms JSXText/StringLiteral using the same rules (harder to maintain).
- Runtime: create a React wrapper that recursively walks children and replaces strings (perf and correctness caveats).
- Hybrid: wrap JSX pages with a thin MDX shell and keep textual content in MDX so the plugin can process it.
Example of the report log
When DEBUG is enabled, the plugin prints a summary of replacements to the console after build/dev:
=== Remark replacements report ===
[blog]
- "Cavo" replaced 4 times
- "kiki" replaced 4 times
[all]
- "Giti" replaced 4 times
======================================
This article is part of the Docusaurus Plugins series:
- Plugin Remark Replace Words
Loading comments…
Related posts

Plugin Simple Analytics
September 14, 2025

Component Tooltip
September 21, 2025

Tutorial Docusaurus React Live
October 1, 2025

Component Trees
October 22, 2025
