Free and open source, runs in your browser
Markdown variables
Fill a Markdown document in with typed application data. Placeholders get resolved inside the parser, so a value can’t turn into Markdown structure. The demo above runs the real variables() plugin in your browser.
Variables docsnpmGitHubReact Markdown Kit
- Values stay textA value goes into the parsed tree as a text node. It can’t add a heading, a table row, a link destination or an HTML tag, and 152 test cases check that.
- Typed and validatedTypeScript generics at compile time, and any Standard Schema validator (Zod, Valibot, ArkType) at runtime. A missing value is an error, never a blank.
- A renderer pluginThere’s no separate engine.
variables()is an extension, so it runs wherever the renderer runs, React or not.
Install the package
npm install @react-markdown-kit/renderer @react-markdown-kit/variablesvariables() is a plugin. The renderer parses with your preset and the plugin fills in the tree. compileMarkdown runs the same extension outside React, in a service, a worker, a CLI, an email job or a PDF pipeline.
Use variables in three steps
- Render a document with data. The source keeps its placeholders, and each render passes the data for one reader.
import Markdown from '@react-markdown-kit/renderer' import { variables } from '@react-markdown-kit/variables' <Markdown extensions={[variables({ data: { user: { name: 'Chatis' } } })]}> {'# Hello {{user.name}}'} </Markdown> - Type the data, then validate it. Generics catch mistakes at compile time. Runtime schemas go through Standard Schema, so Zod, Valibot and ArkType all work without an adapter. The validator is detected by its
~standardmember, andschema.test.tsruns the same document against two vendors. A missing required value is an error diagnostic and renders nothing (or yourfallback), so you won’t send a document with a blank where a number should be (missing-value tests).variables<ReportData>({ data }) variables({ data, schema: ReportSchema }) - Format for the reader’s locale. There are six built-in formatters:
number,currency,percent,date,timeanddatetime.localeandtimeZoneare options onvariables().{{revenue | currency:"USD"}} {{renewsAt | date:"long"}} {{share | percent:1}}
Two formatting rules have their own tests, because getting either one wrong is expensive.
- Currency always carries an explicit ISO 4217 code.
{{revenue | currency}}is an error, because it won’t guess a currency from the locale.currency:"USD"infr-FRrenders$USand stays in dollars (currency tests). - The time zone is explicit and defaults to UTC, so a report doesn’t change depending on which machine rendered it (date and time tests).
More docs. Formatting and localization covers every formatter and its arguments, variables in a .md file walks through a file-based setup, and the Handlebars comparison explains why string interpolation behaves differently.
Data cannot inject structure
Values are placed into the parsed syntax tree as nodes. They’re never substituted into the source text and re-parsed, so a customer named **Administrator** renders as those literal characters. Pick Hostile values in the demo to see it. Three test files cover this, with 152 cases between them:
| What is asserted | Where |
|---|---|
| 22 hostile values in a heading and a paragraph keep the document’s node types and their own characters, and survive a serialize and re-parse | injection.test.ts (77) |
12 line-start constructs, in 5 authored contexts, cannot become structure after documentToMarkdown and a GFM re-parse | variables-serialization-safety.test.ts (62) |
| The same claim written again without the plugin’s own helpers, plus prototype-chain paths | variables-security-independent.test.ts (13) |
Run them yourself:
pnpm test -- plugins/variables/tests/injection.test.ts \
tests/variables-serialization-safety.test.ts \
tests/variables-security-independent.test.tsA few more rules come out of the same design:
- Code contexts are literal. A
{{path}}inside inline code, a fenced block or an indented block is treated as documentation about a placeholder and left alone (literal-context tests). - A URL binding is all or nothing. A placeholder has to be the entire link destination, and the resolved value still goes through the protocol policy, so
javascript:can’t end up in anhref(partial-URL tests and destination policy tests). - Prototype-chain paths never resolve.
__proto__,constructorandprototypeare refused at resolve time, and nothing gets written toObject.prototype(path tests).
Diagnostics include the path but never the runtime value, so they’re safe to log (test). The security model has the rest.
It renders through the same renderer
Resolution happens inside the renderer’s own compile step, so there’s no stringify and reparse in between. You can cache a compiled document and hand it to <Markdown document> later. If you need Markdown text back (for an email body or a file on disk), serialize the compiled document. Serializing re-escapes, so **Administrator** comes back out as \*\*Administrator\*\* and still reads as literal text (serializer tests). The demo’s Markdown tab shows that output.
import { compileMarkdown, documentToMarkdown } from '@react-markdown-kit/renderer'
const document = compileMarkdown(source, { preset, extensions: [variables({ data })] })
await writeFile('report.md', await documentToMarkdown(document))Authoring the document
@react-markdown-kit/variables/editor turns each placeholder into a chip in the rich editor, with a label and a sample-data preview, and adds an insert-variable command to the toolbar. Preview data only changes what the author sees. It’s never saved. The editor demo has it running, and authoring variables covers the options.
import { variableChips } from '@react-markdown-kit/variables/editor'
<MarkdownEditor
value={source}
onChange={setSource}
extensions={[variableChips({ variables, previewData: sample })]}
/>About this demo
- The left side is the authored source and the JSON handed to
variables(). The source stays the same whichever data set you pick. Only the output changes. - Rendered is a
<Markdown>showing the documentcompileMarkdownproduced. Markdown is that document serialized back withdocumentToMarkdown. Diagnostics lists every code the plugin reported. - Hostile values puts Markdown and HTML into the data, and it renders as literal text. Unsafe link binds a
javascript:URL and Missing value drops one amount. Both fail, so nothing renders and the Diagnostics tab says why. - If you’re writing a personalized email or report, personalized Markdown goes through a full setup.
Questions
- Is there a way to fill Markdown variables without re-parsing the output?
- Yes. @react-markdown-kit/variables resolves {{placeholders}} while the renderer parses, so each value goes into the syntax tree as text and the document is never stringified and parsed a second time. There’s no engine object to call either, since variables({ data }) is just a renderer extension. Variable basics.
- Can variable data inject Markdown or HTML?
- No. A value becomes a text node, so it can’t create a heading, a list, a table row, a link destination, an HTML tag or a code fence. That still holds after the document is serialized back to Markdown and re-parsed with GFM. There are 152 cases asserting this across injection.test.ts, variables-serialization-safety.test.ts and variables-security-independent.test.ts. The security model.
- Does it work outside React?
- Yes. The root entry doesn’t import React or Lexical, so compileMarkdown with the same extension runs in a Node service, a worker, a CLI, an email job or a PDF pipeline. scripts/pack-check.mjs checks this by resolving a document from the packed tarball in a project that doesn’t have Lexical installed.
- Which validation libraries work with the schema option?
- Any validator that implements Standard Schema, which includes Zod, Valibot and ArkType. The package doesn’t depend on any of them and detects the validator by its shape, so you don’t need an adapter. Async validators get a diagnostic instead of being silently awaited. Schemas and types.
- Can I share what I typed into this demo?
- Yes. Copy link compresses the document, the data and the locale into the URL hash. Whoever opens the link gets this page with all three filled in, and nothing gets uploaded.