Skip to main content

Content includes across sections

A remark plugin that lets any page pull in the content of another markdown/MDX file, so a shared fragment (a disclaimer, a callout, a reusable code sample) can live in one file and be reused in docs, blog posts, the /samples demo docs, and src/pages MDX pages alike.

Quick reference

  • Syntax, on its own line, with the braces backslash-escaped: \{@include: path/to/file.md\}. The escaping is required because a bare {...} is parsed by MDX itself as a JS expression before any remark plugin runs.
  • The path is relative to the file containing the directive, unless it starts with /, in which case it's relative to the site root (e.g. {@include: /src/includes/disclaimer.md} resolves the same way from anywhere).
  • Includes nest - an included file can itself contain {@include: ...} - and a circular chain is caught and rendered as an inline warning instead of hanging the build.
  • A missing include path also renders as an inline ⚠️ warning rather than failing the build.
  • Included fragments should be plain content - no Docusaurus frontmatter (id, title, etc.) - since they're spliced into the including page rather than rendered as a page of their own.
  • Source: plugins/remark-includes.mjs, registered as a remarkPlugins entry on every content type in docusaurus.config.js.

Why it has to exist​

Docusaurus has no built-in way to splice one markdown file's content into another. MDX's own transclusion via import works for a .mdx file importing a JSX component, but not for pulling in a plain markdown fragment and having it appear as part of the surrounding page's own table of contents/headings - it would render as a nested component's output, not as part of the page.

A third-party package, docusaurus-plugin-includes, does something similar, but it only works by finding the webpack rule belonging to the @docusaurus/plugin-content-docs package and patching that one rule - so it covers a single docs instance at best, not blog/pages/a second docs instance, and it pins Docusaurus 2 peer dependencies. Since this site needs includes everywhere (docs, /samples, blog, and src/pages), it has its own small remark plugin instead, following the same pattern as remark-image-size.mjs and remark-panel-toc.mjs.

Syntax​

Some normal paragraph.

\{@include: ./shared/callout.md\}

More content after the include.

The directive must be alone on its own line (its own paragraph) - it isn't recognized inline in the middle of a sentence. The braces must be backslash-escaped: MDX parses a bare {...} as an embedded JS expression at the syntax level, before remark plugins ever run, so an unescaped directive fails the whole page's build with a "Could not parse expression with acorn" error instead of reaching this plugin. The backslash makes MDX treat the braces as ordinary characters; by the time the remark tree exists, the escape has already been resolved to a literal {@include: ...} text node, which is what INCLUDE_RE matches.

Path resolution​

  • A relative path (./shared/callout.md, ../includes/foo.md) resolves relative to the directory of the file containing the directive.
  • A path starting with / resolves relative to the site root (the directory docusaurus.config.js lives in), regardless of which file or content type contains the directive. This is the form to use for a fragment meant to be shared across docs, blog, and pages, since "relative to the including file" would otherwise differ for each.

There's no single required location for shared fragments; src/includes/ is a reasonable convention for site-wide ones (nothing else currently reads that folder, so it won't collide with any content type's own routing).

How it works​

plugins/remark-includes.mjs is registered per content type's remarkPlugins array. For each page it processes:

  1. It walks the page looking for a paragraph containing nothing but {@include: ...} text.
  2. It resolves the path (see above) and reads the file.
  3. It parses the included file's contents with the same unified processor settings as the including page - reusing this inside the plugin's attacher function, which unified binds to the active processor - so an include can contain MDX components, GFM tables, etc. and they work exactly as they would if written directly in the including page.
  4. It recursively resolves that subtree's own {@include: ...} directives first (relative to the included file's own directory), before splicing the fully-resolved result into the including page in place of the directive paragraph.
  5. It tracks the chain of files already open along the current recursion path; if an include path reappears in that chain, it stops and renders ⚠️ circular include: a.md -> b.md -> a.md instead of recursing forever.
  6. If the resolved path doesn't exist, it renders ⚠️ include not found: ... inline instead of failing the build - so a typo'd path is visible on the rendered page without taking down npm run build.

Registration​

docusaurus.config.js
import remarkIncludesPlugin from './plugins/remark-includes.mjs';

const siteDir = path.dirname(fileURLToPath(import.meta.url));
const remarkIncludes = [remarkIncludesPlugin, { siteRoot: siteDir }];

presets: [['classic', {
docs: { remarkPlugins: [remarkImageSize], beforeDefaultRemarkPlugins: [remarkIncludes] },
blog: { remarkPlugins: [remarkImageSize], beforeDefaultRemarkPlugins: [remarkIncludes] },
// also covers src/pages/*.mdx
pages: { remarkPlugins: [remarkPanelToc], beforeDefaultRemarkPlugins: [remarkIncludes] },
}]],
plugins: [
['@docusaurus/plugin-content-docs', {
id: 'userGuide',
path: 'samples',
remarkPlugins: [remarkImageSize],
beforeDefaultRemarkPlugins: [remarkIncludes],
}],
],

The { siteRoot: siteDir } option is what makes a /-prefixed path resolve the same way regardless of which content type's pipeline is processing the page.

remarkIncludes is registered via beforeDefaultRemarkPlugins, not remarkPlugins. Docusaurus's own remark pipeline runs the admonitions-transform plugin (which turns :::note ... ::: into the actual <Admonition> component) well before it runs anything in remarkPlugins. If an included fragment itself contains an admonition and remarkIncludes only ran as a remarkPlugins entry, the splice would happen after the admonitions pass had already swept the tree - the spliced-in directive node would never get converted, and Docusaurus would log an "unused Markdown directive" warning instead of rendering a callout. beforeDefaultRemarkPlugins runs ahead of that pass, so the include is already spliced in by the time admonitions runs over the full tree.

Adding it to a new content type​

If a future section is added as its own plugin-content-docs instance (the same pattern /samples already uses), add remarkIncludes to that instance's own beforeDefaultRemarkPlugins array the same way - there's nothing else to wire up per content type.

Notes​

  • This is a block-level directive only - there's no inline \{@include: ...\} support in the middle of a paragraph.
  • The directive's braces must be backslash-escaped (\{...\}) in the source file - see "Syntax" above.
  • Used on /portfolio to pull in the samples disclaimer from src/includes/disclaimer.md.
  • Unlike the docusaurus-plugin-includes package this was built to replace, there's no separate "copy shared folders into docs/versioned_docs" step - this site doesn't use docs versioning, so an included file just needs to exist somewhere on disk at build time, in or out of docs/.

Source​

plugins/remark-includes.mjs, the beforeDefaultRemarkPlugins option on the docs/blog/pages presets and the userGuide docs instance in docusaurus.config.js.