Skip to main content

Plugin Remark Replace Words

· 7 min read
Docux
Curious explorer, a bit of a mad experimenter, and a bit of a contributor.

Developer Development License: MIT

This remark/MDX plugin automatically replaces certain words in your .mdx/.md content with other text, an icon, or even a React component (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.

Interactive glossary

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

Create a folder plugins/remark-replace-words in your Docusaurus project, with a file: index.js (the plugin).

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 posts
  • pages: replacements for pages
  • docs: 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 component
  • children (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/remarkreplace.png",
"altText": "Architecture diagram",
"buttonName": "Click me"
}
},
"Cavo": {
"children": "grandpa Christophe"
},
"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.

docusaurus.config.js
// ...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 to pages.
  • 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: a tag and any component already inserted by a previous replacement (prevents re‑traversal).
  • 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}_/"'’-]) with giu flags.
  • Replace by fragments: split the text and insert either plain text or an MDX element (mdxJsxTextElement inline, mdxJsxFlowElement block) with name, attributes (props) and children.
  • children choice: if children is 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: Cavo in the blog section:
    "Cavo": { "children": "grandpa Christophe" }
    In a post, typing
    “Cavo” will render grandpa Christophe
    Rendered Cavo = grandpa Christophe

Word → Icon (LogoIcon)

  • Keys: nana and roki in the pages section:

    "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: Giti and kiki:
    "Giti": { "component": "Tooltip", "props": { "text": "DocuxLab" }, "children": "Global replacement" }
    "kiki": { "component": "Tooltip", "props": { "text": "Documentation" }, "children": "Docux Docs" }
    The word “Giti” anywhere will be replaced by `<Tooltip text="DocuxLab">Global replacement</Tooltip>`. In docs, “kiki” becomes `<Tooltip text="Documentation">Docux Docs</Tooltip>`.
    Rendered: The word “DocuxLab” anywhere will be replaced by <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 all then blog (the latter wins on conflict). Same logic for pages and docs.
  • 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 .md and .mdx pages. Opinion: I chose to convert all my pages to .mdx to guarantee consistent support for MDX components and the plugin everywhere. Handling .jsx or .tsx pages 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

Related posts

Retour en haut