Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mdnix

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.

Usage

let
  mdnix = import ./.;
in
mdnix.lib.render [
  { type = "heading"; level = 2; text = "Hosts"; }
  { type = "paragraph"; text = "One per machine."; }
]
## Hosts

One per machine.

Constructors

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

Config

render uses defaults. For non-default formatting, use renderWith config blocks instead — render = renderWith { }.

  • lineLength (default null) — word-wrap paragraph text to this width. null disables wrapping.
mdnix.lib.renderWith { lineLength = 10; } [
  { type = "paragraph"; text = "one two three four"; }
]
one two
three four

Wrapping 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
four

ATX headings (## text) are single-line by markdown syntax. lineLength never touches heading text.

Nesting

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.

Lists

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
- laptop

A 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
  - laptop

ol 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. third
mdnix.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. three

Lists 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

- laptop

Blockquotes

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

Tables

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 |

Fragments

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.

Links

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.

Footnotes

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.

Project layout

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

Testing

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.

About

Markdown represented as typed Nix data, with a renderer back to text

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages