Skip to main content

Markdown templating: React Markdown Kit versus Handlebars and Mustache

Handlebars and Mustache are string templating engines. They take a string with {{placeholders}}, put values in, and hand back a string. When that string is Markdown, the values are part of the Markdown source before anything parses it.

@react-markdown-kit/template works differently. It's an extension to a Markdown parser, so the document is parsed first, and values are placed into the syntax tree afterwards as text nodes.

Most of this page follows from that one difference.

The same template, the same data, two results​

Here is the document, with the hostile value coming from a contact field on a sign-up form.

## Account review for {{customer.name}}

Prepared for {{customer.contact}}.

Thanks for being with us.

String interpolation, then parse​

The pane below is the actual output of replacing the placeholders in the source and parsing the result, with the value escaped the way Handlebars escapes it by default (HTML escaping: &, <, >, ", ', ` and =). The substitution runs on this page. The left pane is its result, and you can edit it to see how the parser reads it.

Value spliced into the source, then parsed
Markdown (edit me)
Rendered

Account review for Acme Industrial

Prepared for Dana

Your account is suspended

Wire payment to our new bank. .

Thanks for being with us.

The paragraph the author wrote was closed halfway through, a heading the author didn't write appeared, and a link to another host was added under it. No HTML was involved, so HTML escaping didn't help. #, [, ], ( and ) aren't in the escape set, because Handlebars escapes for HTML and this document is Markdown.

Parse, then resolve​

Same template, same data, @react-markdown-kit/template.

Value placed into the parsed tree
Authored templatenever changes
## Account review for {{customer.name}}

Prepared for {{customer.contact}}.

Thanks for being with us.
Resolved for Ordinary contactchanges

Account review for Acme Industrial

Prepared for Dana Okafor.

Thanks for being with us.

The three blocks stay three blocks. Every character of the value is rendered as plain text inside the paragraph the author wrote.

Why escaping does not close the gap​

The obvious fix is to escape the value for Markdown before handing it to Handlebars. That's harder than it looks, for a few reasons.

  • The characters that matter depend on position. A # is a heading only at the start of a line, - is a list only at line start, | is a table only inside a table row, and --- under a line of text is a setext heading. An escaper that doesn't know where the value lands can't know what to escape.
  • A newline is enough. Any construct above needs only line-start position, so a value containing \n can reach it from the middle of a paragraph. The plugin turns every newline in a value into a space for exactly this reason (test).
  • Escaping twice is visible. Escape every * and a customer named A*Star becomes A\*Star in the emailed Markdown that someone re-parses downstream, or A\*Star on screen if the escape doesn't survive.
  • The dialect isn't fixed. Tables, footnotes and strikethrough exist with GFM and not with CommonMark, so the escape set changes with the preset.

Resolving inside the parser avoids the problem. The value never becomes source, so there's nothing to escape, and the serializer re-escapes on the way out because at that point it knows the context (serializer tests).

Side by side​

Handlebars and Mustache@react-markdown-kit/template
What it templatesAny stringA parsed Markdown document
When values are insertedBefore parsingAfter parsing, into the tree
A value can create Markdown structureYesNo (77 cases)
A value can create HTMLEscaped by default; raw with {{{triple}}} or {{& x}}No. A value cannot create an HTML node (test), and a placeholder inside raw HTML is never bound (tests)
Survives serialize and re-parseNot a property of the engineYes (62 cases)
Loops and conditionalsYes, sections and block helpersNo
Custom helpersYes, arbitrary functionsFormatters only, value in and string out
Number, date and currency formattingBring your own helperBuilt in, locale and time zone aware (tests)
Runtime validation of the dataBring your ownschema takes any Standard Schema validator (tests)
Behaviour when a value is missingRenders empty by defaultError diagnostic; the document becomes fallback or nothing (tests)
Placeholders inside code blocksReplacedLeft literal (6 code-context cases)
Prototype-chain pathsHandlebars ships runtime options to control prototype accessRefused at parse and again at lookup (18 prototype-path cases in a 33-case file)
OutputA string you still have to renderA compiled document, or Markdown text through documentToMarkdown

You can run everything in the evidence column with one command.

pnpm test -- plugins/template/tests/ tests/template-table-formatters.test.ts \
tests/template-serialization-safety.test.ts tests/template-security-independent.test.ts

That run is 324 cases across 15 files, of which 152 are injection cases.

Mustache is the same story​

Mustache's {{name}} is HTML-escaped and {{{name}}} is not (mustache(5)), which is the same escape boundary Handlebars draws. Mustache has sections instead of block helpers and no helper functions, so it does less than Handlebars, but for the purposes of this page it behaves the same way. Values are spliced into the source string, and Markdown structure inside a value becomes Markdown structure.

When to stay on Handlebars​

The plugin doesn't replace Handlebars in general. I'd stay with Handlebars if any of these apply.

  • The document needs iteration or branching. A table with one row per line item, or a paragraph that only appears for trial accounts, needs {{#each}} and {{#if}}. The plugin has neither, since a placeholder is a dotted path with an optional formatter, and a(), a[0], a b and 1 + 1 are all rejected as paths (parser tests). In React the usual answer is to loop in the component and render one <Markdown> per item, but that moves the loop out of the document.
  • You're templating something that isn't Markdown, such as an HTML email, a YAML file or a subject line.
  • Non-developers already write Handlebars in your product and the syntax is part of the contract.
  • You need helpers with arbitrary logic. The plugin's formatters take one value and return a string, and there's no {{#compare}}.

You can use both. A common setup is Handlebars for the surrounding machinery, such as the subject line and the HTML wrapper, and the plugin for the Markdown body, where the untrusted values are.

Migrating a Markdown template​

The placeholder syntax matches, so most templates move unchanged.

  1. {{customer.name}} stays {{customer.name}}.
  2. {{{customer.name}}} becomes {{customer.name}}. There's no unescaped form, because nothing gets escaped in the first place (the value never becomes source).
  3. Handlebars helpers become formatters: {{formatCurrency amount}} becomes {{amount | currency:"USD"}}, with the currency code spelled out because a locale never implies a currency (test).
  4. {{#each}} and {{#if}} have no equivalent. Either keep those documents on Handlebars or move the branch into the code that picks the template.
  5. Replace Handlebars.compile(source)(data) and a separate Markdown render with one call:
import { compileMarkdown } from '@react-markdown-kit/renderer'
import { gfmPreset } from '@react-markdown-kit/renderer/gfm'
import { template } from '@react-markdown-kit/template'

const document = compileMarkdown(source, { preset: gfmPreset, extensions: [template({ data })] })

Placeholders inside code fences (where you're writing about the syntax, not using it) stop being replaced, which usually fixes documentation pages that Handlebars kept mangling (literal-context tests).

FAQ​

Can I use Handlebars to template a Markdown file?

Yes, and it works fine as long as you write both the template and the data. Handlebars replaces the placeholder in the source string and the result is then parsed as Markdown, so a value can add a heading, a list, a table row or a link. That's fine for values you control, but risky for values a user typed.

Does Handlebars escaping protect a Markdown document?

Only against HTML. Handlebars and Mustache escape the HTML metacharacters by default, which stops a script tag, but they leave asterisks, hashes, pipes, brackets and parentheses alone, and those are the characters Markdown structure is made of.

What does React Markdown Kit do differently?

It parses the document first and then places values into the syntax tree as text nodes, so a value never becomes source. The document keeps its shape no matter what the data contains, which 152 test cases check, including after the result is serialized back to Markdown and re-parsed.

Does the template plugin have loops and conditionals?

No. It only resolves dotted paths with optional formatters (there are no sections, helpers or expressions). If a document needs iteration or branching, Handlebars has that and this plugin doesn't.

Next​

Markdown template engine · Personalized Markdown · Markdown template variables · Security model · Editor demo · @react-markdown-kit/template on npm · Source on GitHub