ViteHub is still experimental. Expect bugs and breaking changes.

Markdown templates

Render deterministic Markdown from explicit data, bounded conditions, fragments, and caller-resolved imports.

@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.

Terminal
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.

src/review-template.ts
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:

Rendered Markdown
# Review 611

Title: Refine navigation

## Files

- `DocsAsideLeftBody.vue`

Template syntax

SyntaxPurposeBehavior
{{ path.to.value }}Scalar bindingAccepts a string, number, or boolean and escapes Markdown syntax in the value. Missing paths and non-scalar values fail rendering.
{{{ path.to.markdown }}}Markdown fragmentInserts 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 sectionSelects an if, else-if, or else branch. Conditions support data paths, literals, !, equality and inequality (===, !==, ==, and != use strict semantics), &&, ||, and parentheses.
@./relative.mdTemplate importCalls resolveImport for a relative file. Absolute paths, URLs, and globs are rejected.
{{ value }} in a quoted XML-style attributeAttribute bindingEscapes 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.

OptionTypeDefaultPurpose
dataRecord<string, unknown>{}Supplies values for scalar bindings, fragments, and conditions. Paths resolve own properties only.
maxImportDepthnumber4Limits nested imports when resolveImport is present. Use a non-negative integer; 0 rejects every import.
resolveImportResolveMarkdownTemplateImportnoneResolves one relative specifier against the current canonical source id. Without it, relative-looking text remains literal.
sourceIdstring<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.

src/render-instructions.ts
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.

Copyright © 2026