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 AI 90%

Docux in a top hat pulling a rabbit out of a hat on stage, applauded by a theatre full of meerkats

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.

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​

File structure​

▼📁plugins
▼📁remark-replace-words
index.js
replacements.json

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 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/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" }
},
"node1": {
"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 = 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:

Back to top