Markdown templates
@vite-hub/markdown-template composes Markdown without evaluating JavaScript or reading files implicitly. Use it when Agent Instructions, review prompts, or other generated documents need predictable data binding and conditional sections while preserving authored Markdown structure.
Install
The package requires Node.js 24 or later.
pnpm add @vite-hub/markdown-template
Render a template
Pass the template string and the complete data available to it. Scalar bindings are escaped as Markdown text, while triple bindings insert an intentional Markdown fragment.
import { renderMarkdownTemplate } from '@vite-hub/markdown-template'
const markdown = await renderMarkdownTemplate([
'# Review {{ pullRequest.number }}',
'',
'Title: {{ pullRequest.title }}',
'',
'::if{pullRequest.draft}',
'This pull request is a draft.',
'::else',
'{{{ sections.files }}}',
'::',
].join('\n'), {
data: {
pullRequest: {
draft: false,
number: 611,
title: 'Refine navigation',
},
sections: {
files: '## Files\n\n- `DocsAsideLeftBody.vue`',
},
},
})
The result keeps the fragment as document structure:
# Review 611
Title: Refine navigation
## Files
- `DocsAsideLeftBody.vue`
Template syntax
| Syntax | Purpose | Behavior |
|---|---|---|
{{ path.to.value }} | Scalar binding | Accepts a string, number, or boolean and escapes Markdown syntax in the value. Missing paths and non-scalar values fail rendering. |
{{{ path.to.markdown }}} | Markdown fragment | Inserts trusted Markdown without evaluating bindings, conditions, or imports inside the fragment again. Block Markdown is rejected when the binding appears in an inline position. |
::if{condition} | Conditional section | Selects an if, else-if, or else branch. Conditions support data paths, literals, !, equality and inequality (===, !==, ==, and != use strict semantics), &&, ||, and parentheses. |
@./relative.md | Template import | Calls resolveImport for a relative file. Absolute paths, URLs, and globs are rejected. |
{{ value }} in a quoted XML-style attribute | Attribute binding | Escapes HTML attribute characters before inserting the scalar value. |
Template syntax inside code spans, fenced code blocks, and indented code blocks remains literal. Authored XML-style tags remain in the rendered Markdown.
Render options
renderMarkdownTemplate(template, options) returns a Promise<string> and accepts every RenderMarkdownTemplateOptions field below.
| Option | Type | Default | Purpose |
|---|---|---|---|
data | Record<string, unknown> | {} | Supplies values for scalar bindings, fragments, and conditions. Paths resolve own properties only. |
maxImportDepth | number | 4 | Limits nested imports when resolveImport is present. Use a non-negative integer; 0 rejects every import. |
resolveImport | ResolveMarkdownTemplateImport | none | Resolves one relative specifier against the current canonical source id. Without it, relative-looking text remains literal. |
sourceId | string | <template> | Identifies the root template for relative resolution and circular-import detection. |
The import resolver returns { id, template }, where id is the canonical identity used for nested imports and cycle detection.
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { renderMarkdownTemplate } from '@vite-hub/markdown-template'
const sourceId = resolve('instructions/review.md')
const template = await readFile(sourceId, 'utf8')
const markdown = await renderMarkdownTemplate(template, {
data: { repository: { name: 'vite-hub/vitehub' } },
sourceId,
async resolveImport(specifier, importer) {
const id = resolve(dirname(importer), specifier)
return { id, template: await readFile(id, 'utf8') }
},
})
The resolver owns filesystem, URL, authorization, and caching policy. ViteHub resolves imports before evaluating conditional sections, rejects missing resolutions, and stops circular imports, so the resolver must authorize every requested import even when it appears inside an unselected branch.
Security and limits
Scalar escaping prevents untrusted values from becoming Markdown syntax, but rendered Markdown is still data for the next consumer. Triple-bound fragments are trusted input and do not create an instruction or security boundary for a model.
The package deliberately has no loops, helpers, macros, compile phase, HTML renderer, implicit filesystem access, or public syntax-tree API. Prepare repeated sections in application code, pass the finished Markdown as a fragment, and keep import access inside resolveImport.