Markdown as typed Nix data.
A document is a list of tagged block attrsets;
render turns that list into markdown text.
Blocks are plain attrsets. The renderer is the only place that knows markdown syntax.
let
mdnix = import ./.;
in
mdnix.lib.render [
{ type = "heading"; level = 2; text = "Hosts"; }
{ type = "paragraph"; text = "One per machine."; }
]## Hosts
One per machine.mdnix.lib exports a constructor per block type, terser than the raw attrset.
p content also builds text/footnoteRef/link fragments as children when content is a list (see Fragments).
| Type | Constructor | Shape |
|---|---|---|
heading |
h level text |
{ type = "heading"; level; text; footnote ? [ ]; } |
paragraph |
p content |
{ type = "paragraph"; text; footnote ? [ ]; } |
code |
code text |
{ type = "code"; lang ? ""; text; } |
footnote |
fn id text |
{ type = "footnote"; id; text; } |
list |
ul items / ol items |
{ type = "list"; ordered ? false; children; } |
blockquote |
quote children |
{ type = "blockquote"; children; } |
table |
table header rows |
{ type = "table"; header; rows; } |
| table cell | align alignment content |
{ text; } or { children; }, plus optional align ? null |
text (fragment) |
text text |
{ type = "text"; text; } |
footnoteRef (fragment) |
fnref id |
{ type = "footnoteRef"; id; } |
link (fragment) |
ln text url |
{ type = "link"; text; url; } |
Optional extras attach through combinators — verbs applied to a block, not a raw attrset merge:
| Combinator | Effect |
|---|---|
refs ids block |
attaches footnote = ids to block |
lang language block |
attaches lang to block (used by code) |
loose block |
attaches tight = false to block |
render uses defaults.
For non-default formatting, use renderWith config blocks instead —
render = renderWith { }.
lineLength(defaultnull) — word-wrap paragraph text to this width.nulldisables wrapping.
mdnix.lib.renderWith { lineLength = 10; } [
{ type = "paragraph"; text = "one two three four"; }
]one two
three fourWrapping splits only on word boundaries.
A word longer than lineLength gets its own overlong line.
A paragraph nested inside a list item or blockquote has its effective width reduced
by however many characters that marker or > consumes
(see Lists and Blockquotes).
If text already contains a line break, lineLength does nothing to it,
not even reflowing an overlong line.
lineLength only applies to a paragraph with no breaks in it at all,
compatible with Semantic Line Breaks:
one clause per line, untouched regardless of length.
mdnix.lib.renderWith { lineLength = 40; } [
{
type = "paragraph";
text = "First sentence here.\nSecond sentence, broken at the comma,\nfollows after.";
}
]First sentence here.
Second sentence, broken at the comma,
follows after.flow text collapses embedded line breaks into spaces before anything else sees the string,
for a multiline literal that's just editor-wrapped prose rather than sembr.
The result reflows normally under lineLength.
mdnix.lib.renderWith { lineLength = 7; } [
{
type = "paragraph";
text = mdnix.lib.flow ''
one
two
three
four
'';
}
]one two
three
fourATX headings (## text) are single-line by markdown syntax.
lineLength never touches heading text.
Every block accepts an optional children list of blocks.
By default, children render flat underneath their parent: blank-line separated, no indentation added,
exactly like top-level siblings.
{
type = "heading";
level = 2;
text = "Hosts";
children = [ { type = "paragraph"; text = "One per machine."; } ];
}## Hosts
One per machine.list and blockquote (below) are the two constructs where nesting is real: their children get indented or quoted.
Items are ordinary blocks — typically p — given to ul (unordered) or ol (ordered).
A list or blockquote placed right after an item is that item's nested content, not a new sibling item —
nesting in the Nix source mirrors nesting in the rendered markdown, no combinator needed.
{
type = "list";
children = [
{ type = "paragraph"; text = "desktop"; }
{ type = "paragraph"; text = "laptop"; }
];
}- desktop
- laptopA list is the one place children actually nests visually:
the marker width ("- " or "1. ", wider for higher numbers) becomes real indentation,
and eats into lineLength like any other prefix.
mdnix.lib.ul [
(mdnix.lib.p "Hosts")
(mdnix.lib.ul [
(mdnix.lib.p "desktop")
(mdnix.lib.p "laptop")
])
]- Hosts
- desktop
- laptopol numbers items in order;
wider markers ("10. " vs "1. ") indent nested content further to match.
{
type = "list";
ordered = true;
children = [
{ type = "paragraph"; text = "first"; }
{ type = "paragraph"; text = "second"; }
{ type = "paragraph"; text = "third"; }
];
}1. first
2. second
3. thirdmdnix.lib.ol [
(mdnix.lib.p "one")
(mdnix.lib.ol [
(mdnix.lib.p "one")
(mdnix.lib.p "two")
])
(mdnix.lib.p "two")
(mdnix.lib.ol [
(mdnix.lib.p "one")
(mdnix.lib.p "two")
])
(mdnix.lib.p "three")
]1. one
1. one
2. two
2. two
1. one
2. two
3. threeLists default to tight: items are separated by a single newline, no blank line between them.
loose block sets tight = false on block, separating items with a blank line instead.
This also applies to a list rendering as a sibling of another block: a tight list gets no blank line before it,
a loose one does.
Blockquotes use the same tight/loose mechanism.
mdnix.lib.loose (
mdnix.lib.ul [
{ type = "paragraph"; text = "desktop"; }
{ type = "paragraph"; text = "laptop"; }
]
)- desktop
- laptopquote children wraps a list of blocks in a blockquote.
Every line gets a > prefix.
Nested blockquotes compound: an inner one's own > lands inside the outer one's.
{
type = "blockquote";
children = [
{ type = "paragraph"; text = "First."; }
{ type = "paragraph"; text = "Second."; }
];
}> First.
>
> Second.Nesting one blockquote's children inside another compounds the prefix:
{
type = "blockquote";
children = [
{ type = "paragraph"; text = "Outer."; }
{
type = "blockquote";
children = [ { type = "paragraph"; text = "Inner."; } ];
}
];
}> Outer.
> > Inner.table header rows builds a GFM pipe table.
header is a list of cells.
rows is a list of cell lists, one per row.
Cells are text-or-fragments like any other block content, so a link or footnote reference can land inside one.
align "left"/"center"/"right" content attaches column alignment to a header cell;
a cell can also be written directly as { text; align ? null; }.
mdnix.lib.table
[ (mdnix.lib.align "left" "Name") { text = "Age"; align = "right"; } ]
[
[ "Alice" "30" ]
[ "Bob" "25" ]
]| Name | Age |
| :--- | ---: |
| Alice | 30 |
| Bob | 25 |children isn't only for nesting a block below its parent.
A child tagged text or footnoteRef is a fragment:
instead of stacking underneath as its own indented block,
it's concatenated inline to build its parent's own text.
Fragment and structural children coexist freely in the same children list,
told apart purely by type tag —
the same way a footnote block living in children is content, not a nested sub-block.
A block with an explicit text uses it as-is (the common case).
A block with no text derives it from its fragment children instead,
letting a footnote reference land after one specific word rather than only at the end of a block.
The p constructor auto-detects which shape you mean:
a string builds text;
a list containing fragments builds children instead.
[
{
type = "paragraph";
children = [
{ type = "text"; text = "One per machine."; }
{ type = "footnoteRef"; id = "naming"; }
{ type = "text"; text = " See below."; }
];
}
{ type = "footnote"; id = "naming"; text = "Hostnames match room labels."; }
]One per machine.[^naming] See below.
[^naming]: Hostnames match room labels.link is a fragment, not its own block type: { type = "link"; text; url; }, placed inline in children.
{
type = "paragraph";
children = [
{ type = "text"; text = "See "; }
{
type = "link";
text = "the nixtamal manpage";
url = "https://nixtamal.toast.al/manpage/nixtamal-manifest.5/";
}
{ type = "text"; text = " for details."; }
];
}See [the nixtamal manpage](https://nixtamal.toast.al/manpage/nixtamal-manifest.5/) for details.A footnote has two parts:
its content, declared once as a footnote block,
and its references —
the [^id] markers pointing at that content from wherever it's discussed.
| Mechanism | Places the marker |
|---|---|
footnote = [ id ... ] on a heading or paragraph |
after that block's whole text — the common case, no fragments needed |
a footnoteRef fragment |
at an exact point inside a block's text |
footnote blocks (the content) can appear anywhere in the tree,
at any depth.
They never render in place —
instead they're collected, deduped by id (first occurrence wins),
and appended at the end of the document
in the order they're first referenced — not declaration order, not alphabetical by id.
A footnote declared but never referenced still renders,
appended after every referenced one in declaration order,
since there's no reference order to place it by.
[
{ type = "heading"; level = 2; text = "Hosts"; footnote = [ "naming" ]; }
{ type = "paragraph"; text = "One per machine."; }
{ type = "footnote"; id = "naming"; text = "Hostnames match room labels."; }
]## Hosts[^naming]
One per machine.
[^naming]: Hostnames match room labels."b" is declared first here, but referenced second — it renders second:
[
{ type = "footnote"; id = "b"; text = "Second."; }
{ type = "footnote"; id = "a"; text = "First."; }
{ type = "paragraph"; text = "Body."; footnote = [ "a" "b" ]; }
]Body.[^a][^b]
[^a]: First.
[^b]: Second.No flakes.
Inputs are pinned with nixtamal (nix/tamal/)
and resolved with with-inputs (with-inputs.nix, follows.nix),
giving flake-style inputs.foo.lib ergonomics without a flake lock.
outputs.nix builds the actual library;
default.nix ties inputs to outputs.
This file is generated, not hand-edited:
docs/readme.nix is its actual source, as mdnix's own typed blocks built with mdnix's own constructors,
and docs/generate.nix renders it through mdnix's own render — dogfooding the library to document itself.
Regenerate after editing docs/readme.nix with:
nix eval --impure --raw -f docs/run.nix > README.md
nix eval --impure --json -f tests/run.nix
Tests use lib.debug.runTests —
pure eval-time expr/expected pairs, no build required.
An empty list back means every test passed.