Skip to content

Dastfox/mdma-go

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mdma-go

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.

Install

go get github.com/Dastfox/mdma-go

Usage

Values 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 times

RenderFile(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 one

GetInputs(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 applied

ParseFile(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.

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.description when description wasn't set) yields nil rather 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([]) }}).
  • multiple is a reserved word (can't be used as a block or input name), but name is not — name: is only ever recognized in its fixed position inside a multiple block's header, so an input or block literally named name (e.g. name: string) is unaffected.
  • Comparisons (> >= < <=) are only defined for number-number and string-string operands; comparing any other combination returns a *mdma.TypeError rather than panicking. This is stricter than the Python and TypeScript implementations (which respectively raise a raw TypeError or 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.FormatFloat with 'f'), unlike JavaScript's default Number.toString(), which switches to exponential notation for very large or very small magnitudes.

Development

go build ./...
go vet ./...
go test ./...

About

This is the GoLang reference implementation of MDMA. See the language specification and docs for the full grammar, filter reference, and worked examples.

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages