Render one or more Markdown strings from an .mdma template and a typed inputs map.
This is the Go reference implementation of MDMA, a from-scratch templating engine with no dependencies. See the language specification and docs for the full grammar, filter reference, and worked examples.
go get github.com/Dastfox/mdma-goValues passed as inputs (and returned from a template) follow Go's usual
dynamic-JSON shape: string, float64, bool, nil, []any, and
map[string]any. This is exactly what encoding/json produces when
unmarshaling into any, so inputs sourced from JSON need no conversion.
import mdma "github.com/Dastfox/mdma-go"
source, _ := os.ReadFile("release-notes.mdma")
result, err := mdma.Render(string(source), map[string]any{
"project": "Acme SDK",
"version": "3.0.0",
"date": "2026-07-01",
"added": []any{"WebSocket support"},
"breaking": true,
"releases": []any{
map[string]any{"version": "2.1.0", "date": "2026-06-01", "added": []any{"Dark mode"}},
},
})
slug, _ := result.Get("slug") // "Acme SDK-3.0.0" (string)
notes, _ := result.Get("release-notes") // rendered markdown (string)
entries, _ := result.Get("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 }}
named, _ := result.Get("changelog-by-version")
om := named.(*mdma.OrderedMap)
om.Get("2.1.0") // "### 2.1.0 — 2026-06-01\n"
om.Keys() // in declaration/computed order, e.g. ["2.1.0", "2.0.0"]RenderTemplate(tmpl, inputs) renders an already-parsed template (from
ParseFile) — same semantics as Render, minus the parse:
tmpl, _ := mdma.ParseFile(source) // parse once ...
mdma.RenderTemplate(tmpl, map[string]any{"project": "Acme SDK", "version": "3.0.0", "date": "2026-07-01"}) // ... render many timesRenderFile(path, inputs) reads path as UTF-8 and renders it — equivalent
to Render(string(contents), inputs):
result, _ := mdma.RenderFile("release-notes.mdma", map[string]any{"project": "Acme SDK", "version": "3.0.0", "date": "2026-07-01"})WriteOutput(result, outputDir, block) writes a Render/RenderFile result
to .md files. Pass block = "" to write every top-level block (in file
declaration order); 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.
result, _ := mdma.RenderFile("release-notes.mdma", inputs)
mdma.WriteOutput(result, "out/", "") // every block
mdma.WriteOutput(result, "out/", "release-notes") // just that oneGetInputs(source) returns the template's @inputs declarations, and
ValidateInputs(source, inputs) checks an inputs map against them without
rendering — returning the resolved inputs (defaults applied) or an error
(*mdma.MissingInputError / *mdma.TypeError):
decls, _ := mdma.GetInputs(source)
// []mdma.InputDecl{{Name: "project", Type: "string", HasDefault: false}, ...}
resolved, err := mdma.ValidateInputs(source, map[string]any{"project": "Acme SDK", "version": "3.0.0", "date": "2026-07-01"})
// resolved inputs, with declared defaults appliedParseFile(source) is also exported for lower-level access to the parsed
template (inputs and blocks).
Render and friends return one of these error types on failure. Every one
implements the mdma.Error marker interface (errors.As(err, &target)
works with either a concrete type or mdma.Error itself as a catch-all):
| Type | Condition |
|---|---|
*mdma.MissingInputError |
a required input (no default) was not supplied |
*mdma.TypeError |
an input's runtime type doesn't match its declared type, or a name expression evaluates to something other than a string/number |
*mdma.ReferenceError |
a forward block reference, or an undefined variable |
*mdma.FilterError |
a filter was applied to a value of the wrong type |
*mdma.SyntaxError |
the .mdma source doesn't conform to the grammar (including name used without a preceding multiple) |
*mdma.DuplicateNameError |
two items in a multiple block computed the same name value |
WriteOutput's path-traversal guard (a malicious computed name like
"../../evil") is the one exception: it returns a plain error, not an
mdma.Error, matching the Python and TypeScript implementations.
- 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) yieldsnilrather than an error — 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 raises*mdma.ReferenceError. 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([]) }}).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 inside amultipleblock's header, so an input or block literally namedname(e.g.name: string) is unaffected.- Comparisons (
> >= < <=) are only defined for number-number and string-string operands; comparing any other combination returns a*mdma.TypeErrorrather than panicking. This is stricter than the Python and TypeScript implementations (which respectively raise a rawTypeErroror apply JS's loose relational coercion here) — Go has no polymorphic ordering operator, so this is the one place mdma-go deliberately narrows otherwise-undefined behavior instead of mirroring a host-language quirk. - Numbers always format without exponential notation (
strconv.FormatFloatwith'f'), unlike JavaScript's defaultNumber.toString(), which switches to exponential notation for very large or very small magnitudes.
go build ./...
go vet ./...
go test ./...