typescript-mdma
typescript-mdma¶
Render one or more Markdown strings from an .mdma template and a typed inputs object.
This is the TypeScript reference implementation of MDMA. See the language specification and docs for the full grammar, filter reference, and worked examples.
Install¶
Usage¶
import { render } from "typescript-mdma";
import { readFileSync } from "node:fs";
const source = readFileSync("release-notes.mdma", "utf-8");
const result = render(source, {
project: "Acme SDK",
version: "3.0.0",
date: "2026-07-01",
added: ["WebSocket support"],
breaking: true,
releases: [{ version: "2.1.0", date: "2026-06-01", added: ["Dark mode"] }],
});
result.slug; // "Acme SDK-3.0.0" (string)
result["release-notes"]; // rendered markdown (string)
result["changelog-entry"]; // one string per release (string[], from `<multiple:>`)
A <multiple:> block can also declare <name:> to key each item by a computed
name instead of array position:
<changelog-by-version>
<multiple: entry in releases>
<name: entry.version>
### {{ entry.version }} — {{ entry.date }}
result["changelog-by-version"];
// { "2.1.0": "### 2.1.0 — 2026-06-01\n", "2.0.0": "### 2.0.0 — 2026-05-01\n" }
renderFile(path, inputs) reads path as UTF-8 and renders it — equivalent
to render(readFileSync(path, "utf-8"), inputs):
import { renderFile } from "typescript-mdma";
const result = renderFile("release-notes.mdma", { project: "Acme SDK", version: "3.0.0", date: "2026-07-01" });
writeOutput(result, outputDir, block?) writes a render()/renderFile()
result to .md files. Omit block to write every top-level block; pass a
block name to write only that one. A string-valued block becomes
{outputDir}/{block}.md; a <multiple:> block becomes a directory
{outputDir}/{block}/ with one file per item — {name}.md if the block
declared <name:>, otherwise {index}.md. Returns the list of paths written.
import { renderFile, writeOutput } from "typescript-mdma";
const result = renderFile("release-notes.mdma", { ... });
writeOutput(result, "out/"); // every block
writeOutput(result, "out/", "release-notes"); // just that one
render() throws one of the errors in the package on failure:
| Error class | Condition |
|---|---|
MissingInputError |
a required input (no default) was not supplied |
MdmaTypeError |
an input's runtime type doesn't match its declared type, or a <name:> expression evaluates to something other than a string/number |
MdmaReferenceError |
a forward block reference, or an undefined variable |
FilterError |
a filter was applied to a value of the wrong type |
MdmaSyntaxError |
the .mdma source doesn't conform to the grammar (including <name:> used without a preceding <multiple:>) |
DuplicateNameError |
two items in a <multiple:> block computed the same <name:> value |
All extend MdmaError.
Behavioral notes not obvious from spec.md¶
- The blank line conventionally left between one block's content and the next
block's header (or EOF) is treated as file formatting, not part of either
block's rendered value — it's stripped from both ends of the block body
before parsing. This is required for block references (
{{ blockname }}) to be safely embeddable inline; otherwise every block value would carry a stray trailing newline from that separator. Blank lines inside a body are preserved exactly as written. - Whitespace control (
{%-/-%}) is applied per-tag, exactly as written — a conditional branch that renders empty does not retroactively remove surrounding blank-line text unless that text is trimmed by an adjacent-. - Accessing a missing property on an
object/object[]value (e.g.entry.descriptionwhendescriptionwasn't set) yieldsnullrather than throwing — objects are untyped maps, so this is normal and is what makes| default(...)useful on them. An undefined root identifier (typo'd variable/block/input name) still throwsMdmaReferenceError. default([])and other array literals ([a, b]) are supported in expressions even though the formal grammar doesn't enumerate an array-literal production — the filter reference relies on this syntax ({{ list | default([]) }}).- This implementation is a from-scratch parser/interpreter (no Jinja/Liquid dependency), matching the Python package behavior exactly — both share the same golden-output test fixtures.
multipleis a reserved word (can't be used as a block or input name), butnameis not —<name:>is only ever recognized in its fixed position right after<multiple:>, so an input or block literally namedname(e.g.name: string) is unaffected.