Syntax
This is markz’s dialect, one construct per row. It keeps GFM’s everyday symbols, uses directives as its one extension syntax (with
{…} attributes in a few fixed places), and adds a metadata block, math and ${…} expressions. It cuts
everything that makes Markdown need backtracking. There is one way to write each thing. The
rendered site is the primary target. A markz document stays readable on GitHub, but it doesn’t
have to render identically there. Anything markz rejects stays literal text and adds an entry to
doc.diagnostics saying what to write instead. It is never silently reinterpreted.
The cuts and the attribute rules follow djot. The reasons are in the spec.
Fully supported
The same syntax and result as GFM.
& is ordinary text: write it literally, and html() escapes it. The only character references
are numeric ones.
| Construct | Syntax | Notes |
|---|---|---|
| Paragraph | text separated by a blank line | |
| Heading | # to ######, then a space |
Every heading gets an id, and {#id} sets it (Headings). |
| Strong | **text** |
|
| Emphasis | _text_ |
|
| Strikethrough | ~~text~~ |
|
| Inline code | `code`, `` a b `` ` |
Any number of backticks. |
| Fenced code | ``` or longer, then an info string |
The first word is lang and the rest is meta. Nest by using a longer fence. |
| Link | [text](url "title") |
Relative URLs go here: [About](/about). |
| Image |  |
|
| Autolink | <https://…>, <mailto:…>, <me@example.com> |
An absolute URL with a scheme, or an email address, in angle brackets. |
| Blockquote | > on every line |
|
| Bullet list | - item; * and + also accepted |
Changing the marker starts a new list, as in GFM. oxfmt writes * for the second of two adjacent lists. |
| Ordered list | 1. item; 1) also accepted |
The first number sets start. Changing the delimiter starts a new list, as with bullets. |
| Task item | - [ ] todo, - [x] done |
listItem.checked |
| Table | GFM pipe table with a --- delimiter row |
:---, :---: and ---: set alignment. |
| Horizontal rule | --- |
|
| Hard line break | \ at end of line |
A visible, explicit break. GitHub renders it too. |
| Backslash escape | \*, \_, \$, \{, … |
Any ASCII punctuation character. |
| Numeric character | ©, — |
Supported, with limits
Attributes
Directives are the universal extension syntax: components, wrappers around blocks, and inline
spans. {…} is their attribute part. It may also appear in two other places, where it decorates
an element Markdown itself made and a directive would have to wrap or reinvent that element:
{#pricing .center}
## Pricing
{.wide width=600} and the [docs](/docs){target=_blank}.
{.striped}
| Plan | Price |
| ---- | ----- |
| Placement | Applies to | For |
|---|---|---|
| After a directive’s name or label | the directive | components and wrappers |
A line holding only {…} |
the next block | heading ids, and classes on tables, lists, code and paragraphs |
| Directly after an image or link, with no space | that image or link | width, class, target, rel |
- Syntax:
#id,.classandkey=value, withkey="a quoted value"for spaces. Classes accumulate. For other keys, a later value wins. Values may contain${…}. - Block attributes: blank lines may come between the
{…}line and its block, because oxfmt inserts one before a heading. Consecutive{…}lines merge. A{…}line can’t interrupt a paragraph or a table, where it is text. One with no block after it in its container stays text and gets a diagnostic. - On the element:
html()writes block attributes onto the block’s own element: the<p>,<h2>,<table>,<ul>,<blockquote>, and<pre>for code and math. - One line only. djot lets attributes span lines, and markz doesn’t. That keeps the block pass free of lookahead.
- Anywhere else a
{is text. Inline, only a)directly before it can make it attributes, so{a, b},{"json": 1}and prose braces never need escaping. A{…}in one of the three places that doesn’t parse as attributes is text too. - Words and phrases use a text directive:
:span[word]{.highlight}. There is no djot-styleword{.x}or[span]{.x}.
Emphasis rules
The markers are GFM’s: _emphasis_, **strong** and ~~strikethrough~~. Where a run may open
or close follows CommonMark’s flanking rules, without the rest of its 17:
- A run can’t open before whitespace, or before punctuation that follows a letter, and the mirror
image for closing.
_never opens or closes inside a word, sosnake_case_namestays text.**may appear inside a word. - A closer takes the nearest open run of its own kind. There is no rule of 3, and runs don’t
split:
***,____and~~~are text, and**foo****doesn’t nest. *emphasis*where formatters write it. Prettier and oxfmt write_for emphasis except in two places, where_can’t work: emphasis inside_…_(_foo *bar* baz_) and emphasis touching a letter or digit (a*b*c). markz accepts*in exactly those two, so it never rejects formatted output. Anywhere else a*…*pair stays text and is reported, like__…__and~…~.
On ordinary text this matches GFM. Where it disagrees, the spec examples and differential fuzzing
against micromark find the case, and it is either fixed or listed here: the examples that need a
run split, such as ****foo****, are excluded.
Lists
- Content that continues a list item is indented to that item’s content column. There are no lazy continuation lines.
- Tight and loose lists follow GFM: a blank line between items makes the list loose.
Headings
The optional closing #s (## Title ##) are accepted and stripped, as GFM does. A heading is a
single line.
Every heading gets an id, settled as the heading is parsed. No id depends on a later heading, so none changes once it is written, which keeps streaming simple:
{#id}on the line above sets it exactly, giving an anchor that survives renaming the heading. If an earlier heading already has that id, both keep it, the browser uses the first, and the later one is reported.- Otherwise it is generated with GitHub’s algorithm, and numbered
-1,-2, … past any id already used, explicit or generated.
The algorithm:
- Take the heading’s plain text: text and inline-code values, with escapes and numeric
references decoded, punctuation curled, and
\as a space. Link text counts; URLs, image alt text, math and expressions don’t. - Lowercase it.
- Remove every character that isn’t a letter, mark, number, space,
_or-. Letters in any script are kept, and there is no NFKC normalization, as on GitHub. - Trim, then turn each run of whitespace into
-. - If nothing is left, use
section. - If the id is taken, try
-1,-2, … until one is free.
These cases are the contract, and the tests hold to them:
| Heading | id |
|---|---|
## Foo |
foo |
## Foo |
foo-1 |
## Foo 1 |
foo-1-1 |
## Café au lait |
café-au-lait |
## शुरुआत करें |
शुरुआत-करें |
## 日本語の見出し |
日本語の見出し |
## 1. Rename |
1-rename |
## See [docs](https://example.com) |
see-docs |
## ??? |
section |
In a document of its own, a heading whose text looks like a suffix keeps it, and the second
foo goes past it: # foo-1, # foo, # foo give foo-1, foo, foo-2.
Comments
<!-- … --> on lines of its own becomes a comment node, which html() never renders. It is the
only thing kept from HTML. prose’s Markdown notes need it (<!-- @note … -->), and GitHub hides
comments too.
- It may span lines, and ends on the line with
-->. Text after-->on that line is part of the comment and gets a diagnostic. An unclosed comment runs to the end of its container. - A comment that shares its first line with other text is inline, where it is text.
Metadata
A document can open with a metadata block: key/value pairs between --- lines, starting at
offset 0 (what other tools call frontmatter). It is a metadata block only when a closing ---
line follows, every line between looks like metadata (key:, a comment, an indented line or a
blank line), and at least one is a key: line. Otherwise the first --- is a thematic break, so a document that opens with a rule
keeps its content. markz parses it into doc.metadata, a flat object,
and keeps the block’s range.
The rule is JSON-like, with quotes optional: one key: value per line, where a value that doesn’t
look like anything else is a string as written.
---
# a comment
title: Sales Report
summary: 'Make it yours: Cloudflare, secrets.'
order: 2
draft: false
date: 2026-09-26
image:
tags: [svelte, vite]
---
| Value | Result |
|---|---|
nothing, or null |
null |
true, false |
boolean |
42, -3, 1.5 |
number |
"text" |
string, with JSON’s escapes |
'text' |
string, with '' for a quote and no other escapes |
[a, 2, "b, c"] |
a list of values by these same rules, one line, no nesting |
| anything else | string, as written: Sales Report, 2026-09-26, C# notes |
- Keys are
[A-Za-z_][A-Za-z0-9_-]*, and a key appears once. - Comments: a line starting with
#, or#after a value, as in YAML. - Both quote styles are accepted because formatters pick one by configuration (oxfmt writes
single quotes in this repo and double quotes by default). Quote a value that would otherwise
read as something else (
"true","42"), that contains:, or that starts with a character YAML reserves ({ & * ! | > % @, a backtick, or-). In a list, also quote an item that contains,,[or]. - YAML look-alikes are errors, not strings. The block is still YAML to GitHub, editors,
formatters and any YAML parser, and every block markz accepts has the same value under YAML
1.2. So a plain value YAML would read differently gets a diagnostic, not a silent string:
True,FALSE,~,Null,+1,.5,1e3,0x1F,.inf. Write the canonical form or quote it. - Everything else in YAML is out: indented lines (nested maps,
- itemlists, multi-line strings),|and>,{a: b}, anchors, aliases and tags. Each gets a diagnostic, and its key is skipped. Of two duplicate keys, the first wins and the second gets a diagnostic.
markz is not a YAML parser. The yaml package is its dev-only test oracle, as micromark is for
the Markdown: every accepted block must give the same object from both.
Directives
The syntax is micromark-extension-directive’s, with one cut. The trailing {…} follows the
attribute syntax above:
- text:
:name[label],:name{attrs}or:name[label]{attrs}. A label or attributes is required, sohello :worldand10:30stay plain text: a colon in prose is never special, and the parser knows it has a directive as soon as it reaches the[or{. A bare:nameis not a construct in markz, so it gets no diagnostic. This is the fix for micromark’s long-standing complaint (directive#33), where bare:nameswallows prose. - A name starts with a letter, so
localhost:8000is never a directive. A text directive may start inside a word (H:sub[2]O), as in micromark, since the label or attributes already make it deliberate; it can’t start straight after another:. - leaf:
::name[label]{attrs}, on a line of its own. A bare::nameis allowed, since the line can’t be prose. - container:
:::name[label]{attrs}…:::. The closing fence needs at least as many colons as the opening one, and the outermost open directive it can close takes it, as in micromark. So nest with a longer outer fence (::::outeraround:::inner), as with code fences. An unclosed container runs to the end of its own container or the document.
Directives are how components with data are written, since there is no HTML:
::chart{data="sales" type="bar"}
:::callout{type="warning"}
Markdown **inside**, parsed and source-mapped.
:::
html() writes a <div> for container and leaf directives and a <span> for text directives.
:span[text]{.x} is the plain inline wrapper. The name becomes the first class, and the attributes
are written as they are for any element.
Six text-directive names are HTML’s own inline elements, for text that needs its real tag rather
than a styled span. html() writes them as that element, with the attributes and no name class:
| Source | HTML |
|---|---|
x:sup[2] |
x<sup>2</sup> |
H:sub[2]O |
H<sub>2</sub>O |
:ins[new] |
<ins>new</ins> |
:mark[text] |
<mark>text</mark> |
:kbd[Ctrl] |
<kbd>Ctrl</kbd> |
:abbr[HTML]{title="…"} |
<abbr title="…">HTML</abbr> |
They are still directive nodes in the AST, so a consumer’s fold sees them like any other. There
is no :del, because ~~text~~ already writes <del>: an edit is ~~old~~ :ins[new]. The names
apply to text directives only. ::sup and :::mark are ordinary divs.
The label means different things by kind:
- leaf and text: the label is the content. It is parsed as inline Markdown, and its nodes are the directive’s children.
- container: the body is the content, and the label is metadata: a title for a callout, a
summary for a disclosure. It is plain text, with backslash escapes decoded and no inline
parsing, and
html()writes it first, in its own element:
:::callout[Warning]{.important}
Body **here**.
:::
<div class="callout important">
<div class="directive-label">Warning</div>
<p>Body <strong>here</strong>.</p>
</div>
A consumer’s own fold, such as visdown’s Svelte codegen, maps names to components and labels to their props.
Math
- inline:
$…$, by pandoc’s rule. The opening$is followed by a non-space character, and the closing$follows a non-space character and isn’t followed by a digit, socosts $5 and $10stays text.${always starts an expression and never math. - block:
$$fences on lines of their own.
The node holds the raw TeX, and markz doesn’t typeset it. html() writes GitHub’s shape
(<code class="language-math math-inline">, and <pre><code class="language-math math-display">
for a block), and the host adds KaTeX or Temml. A ```math
fence stays an ordinary code block with lang: "math", and its HTML is already the
language-math shape.
Expressions
${…} is a JavaScript template-literal interpolation, parsed as an expression node that holds
the code and its range. markz never evaluates it.
- It is recognised in any inline position, in link destinations and in attribute values.
- It binds tighter than emphasis, the way inline code does, so
${a * b * c}is one expression. - The closing
}is found by tracking brace depth, which also skips strings, template literals (including nested${}) and comments inside the code. - It is inert inside inline code, fenced code, math and autolinks.
\${is a literal${.- An unclosed
${is text. - markz only finds the matching
}. It never validates the JavaScript, so malformed code whose braces, strings and comments close is still an expression.${foo /* } */ + {a: 1}}is one expression. - Regex literals aren’t recognised, because telling
/as division from/opening a regex needs a JavaScript parser. A}inside a regex (${s.replace(/}/g, '')}) closes the expression early. Write it as\u007d, or move the regex out of the document. html()writes the literal source text, escaped.
Raw blocks
A fenced block whose info string is =html is raw output. html() writes its content out
verbatim. It is the only way to put HTML in a document, and it’s explicit, so it needs no
backtracking:
```=html
<iframe src="https://w.soundcloud.com/player/?url=…" height="166"></iframe>
```
- It’s for embeds, inline SVG, and
<style>or<script>a page needs. There’s no=cssor=js: in djot,=formatnames an output format (=html,=latex), not a language. CSS and JavaScript go inside=htmlas<style>and<script>. - A raw block for any other format (
=latex) is kept in the AST, andhtml()skips it. - An ordinary
```cssor```jsfence is code to show, never to run. What a consumer executes (visdown’sjscells) is the consumer’s own decision. - Raw blocks are trusted content: see the spec’s security section.
- On GitHub a raw block shows as a code block.
Line breaks and verse
\at the end of a line is a hard break. It is the only explicit break.- A poem or a quote with its own line breaks gets
{.verse}on the line above.html()writes it as a normal paragraph,<p class="verse">, and the paragraph’s newlines are kept in the output. The site’s CSS.verse { white-space: pre-line }shows them. The\on every line isn’t needed.
{.verse}
Moko kahan dhundhe re bande
Main to tere paas mein
Non-breaking space
\ followed by a space is a non-breaking space (U+00A0), as in djot: 10\ km, Dr.\ Smith.
GFM keeps both characters as text. In a heading id it counts as a space.
Smart punctuation
Built in, as in djot. It applies to text only, never to code, math, expressions, URLs or attribute values.
| Source | Text value |
|---|---|
"quoted" |
“quoted” |
'quoted', don't |
‘quoted’, don’t |
-- |
– (en dash) |
--- |
— (em dash). A line holding only --- is still a horizontal rule. |
... |
… |
- Whether a quote opens or closes is decided by the character before it: start of text, whitespace, an opening bracket, a dash, another quote or an emphasis marker means it opens.
- A run of more than three hyphens is split into em and en dashes with the same count.
\",\',\-and\.keep the straight character.- The text node’s
valueholds the typographic character, and its range still covers the source characters.valueandtextContent()are the rendered text: escapes and numeric references decoded, punctuation curled. What the author typed is alwayssource.slice(start, end), and a consumer that needs the source uses that. - Heading ids are made from the typographic text. Quotes and dashes are punctuation, so they
drop out, and
Don'tandDon’tgive the same id.
Not supported
Each of these stays literal text and adds a diagnostic over exactly its characters, with the
supported form as instead, worded as the table’s second column. Two look-alikes are ordinary
prose, so they stay text without a report: a lone [x] (a reference link’s definition is
reported instead) and a bare {…}.
| Syntax | Write instead | Why |
|---|---|---|
| Raw HTML blocks and inline tags | a ```=html raw block, or directives and attributes |
Seven HTML-block kinds and a tag grammar. HTML stays possible, but only where it’s marked. |
Setext headings (Title over === or ---) |
# Title |
A paragraph would turn into a heading when the next line is read. |
| Indented code blocks | fenced code | Indentation meaning code is what makes list indentation hard. The indented line is paragraph text, and never a heading or list inside it. |
~~~ fences |
a longer backtick fence | One fence character. |
Reference links: [x][y], [x][], [y]: url |
inline links | A link can’t be resolved until the whole document is read, which breaks local parsing and streaming. |
Bare URLs (https://…, www.…, me@example.com) |
<https://…> or [text](url) |
GFM’s largest construct, and the only one that has to look back at text already emitted: an email is known only at its @, and trailing punctuation is trimmed afterwards. |
Relative autolinks (</docs/intro>) |
[About](/about) |
An autolink needs a scheme, and </about> is a closing HTML tag, reported as raw HTML. A link should have real text. |
Named character references (©, &, ) |
the character itself (©, &), or \ for a non-breaking space |
Files are UTF-8, html() escapes & and < itself, and the table of 2,125 names is about 12 KB gzip. |
| Two trailing spaces as a line break | \ at end of line, or {.verse} on a poem |
Invisible syntax. |
__strong__ |
**strong** |
One marker. oxfmt rewrites it. |
*emphasis*, except inside _…_ or touching a letter (Emphasis rules) |
_emphasis_ |
One marker, and the source of most emphasis edge cases. oxfmt rewrites it. |
***, ___, * * * rules |
--- |
One marker. |
~single~ strikethrough |
~~text~~ |
One marker. oxfmt rewrites it. |
Trailing heading attributes (## Title {#id}) |
{#id} on the line above |
Under djot’s rule this {…} belongs to the word “Title”. |
| Multi-line attributes | one line | Keeps the block pass free of lookahead. |
Attributes after words, inline code or emphasis (word{.x}), and djot spans ([text]{.x}) |
:span[text]{.x} |
Directives already wrap inline text, so one way. Keeping { special only after a ) means braces in prose are plain text. |
MDX: JSX (a capitalised tag, <Chart />) and bare {…} expressions |
directives, ${…} |
A { is only attributes where the rules above say so. Any other brace is prose, so a bare {…} stays text without a report. |
Footnotes ([^label], [^label]: text) |
a text directive, such as :note[text] |
A reference can’t be resolved until the whole document is read, as with reference links. Nothing we write uses them. |
TOML metadata (+++) |
a --- metadata block |
One format. |
Lazy continuation lines (a quoted or listed paragraph continuing without > or indentation) |
> on every line, or indent to the item’s content column |
Lazy lines are the main reason CommonMark’s block structure depends on context. Formatters already write them out in full. |
Canonical form
markz’s “one way” is what oxfmt writes. oxfmt matches Prettier’s Markdown output, and this repo
formats with it. We checked by running vp fmt over every alternate form:
| oxfmt rewrites | to |
|---|---|
*em*, __strong__, ***both*** |
_em_, **strong**, _**both**_ |
~one~ |
~~one~~ |
* item (for a first list) |
- item |
1) in a first list |
1. |
***, ___ |
--- |
~~~ fences |
``` fences |
## Title ## |
## Title |
oxfmt leaves these alone: setext headings, indented code, two-space breaks, named entities, bare URLs, reference links and raw HTML. For those, markz’s diagnostic is the only signal.
oxfmt keeps the attribute syntax, with two quirks the rules above absorb:
- It inserts a blank line between a block-attribute line and a heading that follows it.
- It keeps
{.x}directly after an image or link.
One case needs care. For two adjacent lists, oxfmt keeps them apart by switching the marker
(- then *, 1. then 1)). That is why markz accepts all of GFM’s list markers. Rejecting
* or ) would reject oxfmt’s own output.
Pending decisions
None right now. The amitkaps.github.io audit settled raw blocks, verse and smart punctuation. Its Markdown gets migrated to the dialect:
<img>becomes{…}.- The
<div class="video-container">wrappers become:::video-container. <br>becomes a trailing\.- Embeds, SVG and the Stripe script go into
```=htmlblocks. - Poems get
{.verse}. <sup>,<sub>,<ins>and<abbr>become:sup[…],:sub[…],:ins[…]and:abbr[…]{title=…}.- Named references become
&,\and—.