Partials
Reusable markdown components spliced into emails with the include directive.
Partials are named blocks of markdown you define once and reuse across emails: a legal footer, a branded header, a recurring promo card. The document references them with the include directive, and edits to a partial propagate to every email that includes it.
# Your order has shipped
Track it from your account page.
::: include legal-footerPartials are supplied to render() as a map of name → markdown string:
import { render } from 'emailmd';
const { html } = await render(markdown, {
partials: {
'legal-footer': `::: footer
**Acme Inc.** · 123 Main St, Portland OR
[Unsubscribe]({{unsubscribe_url}}) · [Preferences]({{prefs_url}})
:::`,
},
});The same map works everywhere RenderOptions is accepted; pass it to the builder's renderOptions prop and includes expand live in the preview.
There is no filesystem convention: emailmd also runs in the browser, so where partials live is up to your app. Server-side, loading them from a directory is a few lines:
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
const dir = './emails/partials';
const partials = Object.fromEntries(
await Promise.all(
(await readdir(dir)).map(async (f) => [
path.basename(f, '.md'),
await readFile(path.join(dir, f), 'utf8'),
]),
),
);
const { html } = await render(markdown, { partials });The CLI has this built in: emailmd input.md --partials ./partials loads every .md file in the directory, with subdirectories as name prefixes (blocks/legal.md → ::: include blocks/legal). The flag also accepts individual .md files (named by basename) and is repeatable, with later paths winning on name collisions.
Parameters
Pass parameters on the include line to fill {{key}} placeholders inside the partial. This is what turns an include into a reusable component. Values with spaces need quotes:
::: include promo-card title="Summer sale" url="https://acme.com/sale"partials: {
'promo-card': `::: callout
**{{title}}**
[Shop now]({{url}}){button}
:::`,
}Only keys explicitly passed on the include line are substituted. Every other {{token}} passes through untouched for your app's template layer (Handlebars, Liquid, …) to resolve after render, following the same pass-through contract as the rest of the pipeline. In the footer example above, {{unsubscribe_url}} survives into the output HTML exactly as written.
What a Partial Can Contain
Expansion happens textually before the markdown is parsed, so a partial can contain anything a document can: directives, buttons, images, tables, even other includes. Nested includes expand up to 10 levels deep, and cycles are detected and skipped.
The one exception is frontmatter: a partial that starts with a --- block gets it stripped with a warning. Frontmatter belongs on the document.
Partial names may contain letters, digits, _, -, ., and /, so a naming scheme like blocks/legal works if you want namespacing.
Errors Never Break the Render
Consistent with the rest of emailmd, problems degrade with warnings instead of throwing:
- An unknown partial name drops the include line and warns.
- A cycle (a partial including itself, directly or transitively) is skipped with a warning.
- Nesting deeper than 10 levels stops with a warning.
Include lines inside code fences and indented code blocks are left alone, so you can document the syntax itself in an email.
Partials vs. Snippets vs. Wrappers
- Snippets (in the builder) insert a copy at the cursor; later edits to the snippet don't affect emails already written.
- Partials are live references: edit the partial and every email that includes it changes on the next render.
- Wrappers replace the whole document chrome in code, the right tool for layout-level control rather than content reuse.
Custom Pipelines
If you bypass render() and drive the pipeline yourself, the expansion pass is exported as expandPartials(content, partials, warnings); call it on the frontmatter-stripped content before parseMarkdown.