Release candidate 0.0.0-rc.2 Source: spec/v0.md
Markset v0 — Core Tag Vocabulary
Status: Release candidate 0.0.0-rc.2, 2026-09-14. The conformance suite passes against the reference implementation, the §9 questions are decided, and the naive-output contract has been verified on GitHub. The candidate period exists to write real documents against the spec; it becomes v0 when that has happened without a change to §2 or §4. Four documents have been written against it so far, and the two most recent forced no change to §2 or §4.
Change policy. Once v0 is declared, changes within v0 are additive only: new diagnostics, new theme tokens, new reserved classes, clarifications that do not change the parse of any existing conformance case. Anything that changes an existing case's ast, valid, or diagnostics is a breaking change and belongs to v1, which will declare itself with a new markset: value. The conformance suite is the arbiter: a proposed change ships with its cases, and the cases decide which kind of change it is. During the candidate period the same rule applies, except that a breaking change may still land in v0 with a new -rc.N tag and a changelog entry.
0. Scope
This document defines the v0 syntax and tag vocabulary: the grammar for attributes and directives, the eight core block constructs, the reserved class names, the theme token schema, and the degradation contract every construct must satisfy.
It is deliberately small. The v0 bar is: can a single implementer build a conformant parser and both renderers in a few weeks? Constructs that fail that bar are listed in §8 with the reason.
Markset is a strict superset of CommonMark. Every valid CommonMark document is a valid Markset document with identical output.
1. Design invariants
These govern every decision below. When a proposed feature conflicts with one, the feature loses.
- Semantic, never presentational. Authors name intent (
card,metrics). Appearance comes from the theme. No inline CSS, no color values, no pixel dimensions in document source. - Every construct degrades. Each one has a defined CommonMark equivalent and a readable raw-source fallback. This is normative, not aspirational — see §3.
- Closed vocabulary. Unknown directive names are a validation error, not silent passthrough. This is what makes documents checkable and makes generated output reliable.
- No code execution. Nothing in v0 evaluates anything.
- Reuse convergent syntax. Attribute specifiers and directive fences already exist in Pandoc, djot, MyST, and remark-directive. Markset adds a vocabulary on top; it does not add a fourth spelling.
2. Grammar
2.1 Attribute specifier
{#id .class .other key=value key2="quoted value"}
- Braces contain a whitespace-separated list of items. Whitespace is spaces and tabs; a specifier never spans lines. Leading and trailing whitespace inside the braces is permitted, and
{}is a valid, empty specifier. #namesets the id. If repeated, the last wins..nameappends a class. Repeatable; repeats are kept in order and not deduplicated.key=valuesets an attribute. If repeated, the last wins. Bare values may not contain whitespace,}, or quotes ("or'), and may not be empty; otherwise use double quotes. Backslash escapes\"and\\inside quoted values; any other backslash sequence is an error. Writekey=""for the empty string.- Identifiers match
[A-Za-z][A-Za-z0-9_-]*. Ids, classes, and keys are all identifiers. - The keys
idandclassare reserved. Use#nameand.name. - An unrecognized key on a known directive is a validation warning; an unknown class is allowed (themes may define their own).
Parse result. The AST node is { "type": "attributes", "id": <string|null>, "classes": [<string>...], "attrs": { <key>: <value> } }. All four fields are always present.
Diagnostics. A malformed item produces one diagnostic, the parser skips to the next whitespace or }, and parsing continues, so one specifier can report several problems. All are errors.
| Code | Trigger |
|---|---|
ATTR_UNTERMINATED |
No closing } before end of input |
ATTR_UNEXPECTED_CHAR |
Item begins with a character that cannot start an item, such as = or " |
ATTR_BAD_IDENTIFIER |
Id, class, or key name does not match the identifier grammar, including an empty name after # or . |
ATTR_EXPECTED_EQUALS |
A key is not followed by = |
ATTR_EMPTY_VALUE |
key= with no value |
ATTR_BAD_VALUE |
A bare value contains a quote character, or text follows a quoted value's closing quote |
ATTR_UNTERMINATED_QUOTE |
A quoted value has no closing " before end of input. Quoted values may contain }, so this consumes the rest of the input and ATTR_UNTERMINATED is not also reported |
ATTR_BAD_ESCAPE |
A backslash in a quoted value is followed by anything other than " or \ |
ATTR_RESERVED_KEY |
The key is id or class |
Generated heading ids. A renderer producing HTML should give every heading that has no id of its own a generated one, so a document's sections can be linked without the consumer post-processing the output. An explicit #id always wins, and an id already present anywhere in the document is never generated a second time. The exact spelling is reference output rather than normative (§7), and a generated id is not bound by the identifier grammar above: the reference implementation lowercases the heading's text, drops emphasis and code markers, collapses each run of characters that is not a Unicode letter or digit to one hyphen, falls back to section when nothing is left, and appends -2, -3 and so on to resolve collisions in document order. A card's title and a tab's label are inline content on their construct rather than headings, so they are not given ids; the construct takes one from its own specifier.
2.2 Bracketed span (inline)
[text]{.badge tone=warn}
Applies attributes to an inline run. This is the only inline construct in v0 — there are no inline directives. Anything you would express as :name[text]{} is expressed as a span with a reserved class instead.
Grammar. [, inline content, ], then immediately {, an attribute specifier, }. The text may contain nested brackets and a backslash-escaped ], and may span lines within a paragraph; the specifier must close on the same line it opens, and a } inside a quoted value does not close it. If ] is not immediately followed by {, or the specifier never closes, the brackets are ordinary CommonMark (a link, a reference, or literal text) and no diagnostic is reported. Images take no attribute specifier in v0.
Parse result. { "type": "span", "attributes": <attributes node>, "children": [<inline>...] }.
HTML: <span class="ms-span <classes>" id="..." data-<key>="<value>">. Every key=value becomes a data- attribute; spans have no schema.
Diagnostics. ATTR_ codes from the specifier, reported with document offsets.
2.3 Block directive
:::name{#id .class key=value}
content
:::
- Opening fence is three or more
:immediately followed by the directive name. - An optional bracketed argument may follow the name:
:::card[Pricing tiers]. Its meaning is per-construct (usually a title). It contains inline content. - An optional attribute specifier follows the argument.
- The closing fence is a line of at least as many
:as the opening fence, with nothing else on it. - Nesting uses longer outer fences, as with code fences.
- Unclosed directives are implicitly closed at the end of the enclosing block. This is a validation warning.
- The name is required. Bare
::: {.foo}(Pandoc's anonymous fenced div) is not valid Markset — the vocabulary is closed.
Fence lines. A line whose content, after at most three spaces of indentation, begins with three or more : is a fence line. If only whitespace follows the colons it is a closing fence; otherwise it is an opening fence. The name, argument, and attribute specifier follow one another with no whitespace between them, and only whitespace may follow the last part. The name is an identifier (§2.1) and must be one of the block directive names in §4: card, grid, columns, tabs, steps, metrics, figure. The argument is raw source text up to the matching ]; brackets nest, and a backslash-escaped ] does not close it. It is parsed as inline content later.
Line parse result. An opening fence is { "type": "directive-open", "fence": <int>, "name": <string|null>, "argument": <string|null>, "attributes": <attributes node> }; name is null only when the line is malformed, argument is null when no [...] is present, and attributes is always present, empty when no specifier is written. A closing fence is { "type": "directive-close", "fence": <int> }.
Blocks. An opening fence starts a container whose content is parsed as a sequence of blocks, so directives nest and may hold lists, tables, code, or other directives. The container ends at the first closing fence of at least the opening length, or with the enclosing block: a blockquote, a list item, or the document. Directives may interrupt a paragraph, like fenced code. A closing fence inside content closes the innermost open directive regardless of what else is open, so a code block or nested directive that itself contains a ::: line needs a longer outer fence, as with code fences. A malformed opening fence still opens a container, so its content is parsed and reported rather than lost.
Document parse result. { "type": "directive", "name": <string|null>, "argument": [<inline>...] | null, "attributes": <attributes node>, "children": [<block>...] } as a block node in an mdast tree. The argument is parsed as inline content; when it is unterminated it is null.
Diagnostics. Errors from an embedded attribute specifier are reported with their ATTR_ codes. The fence line itself adds:
| Code | Trigger |
|---|---|
DIRECTIVE_MISSING_NAME |
Opening fence with no name, such as ::: {.foo} or :::[Title] |
DIRECTIVE_BAD_NAME |
Name does not match the identifier grammar |
DIRECTIVE_UNKNOWN_NAME |
Name is not in the closed vocabulary; col is a separator name, not a block name |
DIRECTIVE_UNTERMINATED_ARGUMENT |
[ with no matching ] on the line |
DIRECTIVE_TRAILING_CONTENT |
Anything but whitespace after the last part, including a space before [ or { |
The document parser adds two warnings:
| Code | Trigger |
|---|---|
DIRECTIVE_UNCLOSED |
No closing fence before the enclosing block ended; the directive was closed implicitly |
DIRECTIVE_STRAY_FENCE |
A line of three or more colons with no open directive to close. It is ordinary paragraph text |
2.4 Separator directive
::name{attrs}
A line of exactly two : plus a name. It does not open a block; it separates sibling regions inside a parent directive that declares separator children. In v0 only ::col exists, inside :::columns. Encountered anywhere else, it is a validation error.
This exists so that columns doesn't require four levels of fencing to express two columns.
Separator lines. A line whose content, after at most three spaces of indentation, is exactly two : immediately followed by an identifier, an optional attribute specifier, and nothing else but whitespace. A line beginning with :: that is not followed by an identifier is ordinary text, not a separator. Separators take no bracketed argument.
Parse result. { "type": "separator", "name": <string>, "attributes": <attributes node> }.
A separator may interrupt a paragraph. In a document it is a block node with the same shape as the line parse result, minus nothing: { "type": "separator", "name": <string>, "attributes": <attributes node> }.
Diagnostics. DIRECTIVE_UNKNOWN_NAME when the name is not col, DIRECTIVE_TRAILING_CONTENT for anything but whitespace after the last part, and ATTR_ codes from the specifier. A separator whose parent is not a columns directive, including one nested inside a card inside columns, is the error SEPARATOR_OUTSIDE_PARENT, reported by the document parser rather than the line grammar.
2.5 Attribute line
{.lead}
Opening paragraph, larger.
{#pricing .striped}
| Plan | Price |
|------|-------|
A line holding nothing but an attribute specifier attaches its attributes to the block that starts on the very next line. This is djot's block attribute, and it is how the reserved classes in §5 reach paragraphs, headings, lists, tables, code blocks, and blockquotes, none of which have a specifier of their own.
- Up to three spaces of indentation, then
{, the specifier,}, then only whitespace. Anything else on the line makes it an ordinary paragraph. - It never interrupts a paragraph. A
{...}line inside a paragraph is text. - Consecutive attribute lines combine: classes accumulate in order; a repeated id or key follows the last-wins rule of §2.1.
- The target may be any CommonMark block, including the blockquote of a callout. It may not be a directive or separator, which carry their own specifier: that is the error
ATTR_LINE_ON_DIRECTIVE. - An attribute line followed by a blank line or by nothing is the error
ATTR_LINE_ORPHAN. - A reserved class (§5) on a block outside its "Applies to" column is the warning
CLASS_MISAPPLIED; the class is kept. - A
---thematic break directly after an attribute line is the warningDEGRADATION_SETEXT_RULE: plain CommonMark reads the pair as a setext heading. Write the rule as***or___. - Inside
tabs, an attribute line before a tab heading moves to that tab: thetabnode gainsattributes, and HTML puts them on the panel. Insidegrid,steps,metrics, orfigure, an attribute line before the single content block is the errorATTR_LINE_ON_CONTENT, because the renderer replaces that block; use the directive's own specifier. - Inside a list item, attribute lines that open the item attach to the item itself: the whole first line is the marker and a specifier,
- {.hot}, and the item's blocks follow on indented lines. ThelistItemnode gainsattributes. An item has no line of its own before its content, so this is the only spelling that can reach it; the item's first block cannot be attributed separately, and a blank line after the leading lines does not orphan them. This is how one grid item or one step is singled out.
Parse result. The target node gains attributes: <attributes node>; the attribute line itself does not appear in the tree. For a callout the field sits on the callout node.
HTML. id, class, and data-<key> on the block's element: <p class="lead">, <h2 id="pricing">, <ul>, <li>, <table>, <blockquote>, <hr>, and for a fenced code block the <code> element. Inside grid and steps a list item's attributes go on its ms-grid-item or ms-step element, after the ms- class.
Downgrade. Dropped. Naive output. The line merges into a following paragraph as visible text, or stands as a one-line paragraph before any other block; nothing is lost.
3. The degradation contract
Every construct defines two fallbacks. Both are normative and both are covered by the conformance suite.
Downgrade output. The result of running the document through the downgrade renderer, which emits plain CommonMark. This is what you get when publishing to a target that has never heard of Markset. The mapping is specified per-construct in §4. In addition: a bracketed span becomes its text with the attributes dropped; frontmatter is kept; a directive that failed validation contributes its content without the fence; a misplaced ::col contributes nothing. The renderer's serialization choices are fixed so the suite can pin output: - bullets, * for emphasis and strong, fenced code, and GFM tables padded to column width.
Naive output. What a stock CommonMark parser produces from raw Markset source, with no Markset support at all. The spec does not fix the exact output, but requires:
- Content blocks appear in source order.
- Content blocks retain their CommonMark block type — a list stays a list, a table stays a table.
- No construct causes its content to be swallowed, reordered, or absorbed into a code block.
- Fence lines may appear as visible text. This is acceptable. Content mangling is not.
The practical test, and the one worth putting in the README: paste a Markset document into a GitHub comment. It should read as a slightly noisy but complete document, never as syntax soup.
The contract holds because a stock parser reads an opening fence as a paragraph, and bullet lists, ordered lists starting at 1, fenced code, ATX headings, tables, and blockquotes all interrupt a paragraph. Three blocks do not: an ordered list starting at another number, indented code, and a setext heading. Placed directly after an opening fence they merge into it and the block is lost. The parser reports this as the warning DEGRADATION_BLANK_LINE; a blank line after the fence fixes it. A closing fence directly after a list, paragraph, or table joins that block as a lazy line or a final table row, which is visible text and allowed; authors who care about the naive rendering leave a blank line before the closing fence too.
Verified on GitHub, 2026-09-14. examples/showcase.md pasted into a GitHub comment kept every block with its type in source order. Observed noise, all permitted: fence lines and ::col as one-line paragraphs, a closing fence after a table as an extra row, a closing fence after a list as a lazy line in the last item, span attributes as literal text, and, in comments only, frontmatter as a setext heading (repository file views render it as frontmatter). Callouts with a title or fold suffix render as plain blockquotes with the marker visible, because GitHub's own alert syntax is the bare marker (§4.1).
The conformance harness checks the naive contract mechanically on every document case: the sequence of list, table, code, heading, blockquote, and thematic-break blocks in the Markset tree must appear, in order, in a stock CommonMark parse of the same source (GitHub-flavored tables and frontmatter enabled). Cases that document a DEGRADATION_ warning are exempt.
4. Core constructs
Eight block constructs. HTML output uses the ms- class prefix throughout.
HTML conventions. Every construct element carries its ms- class first, then the author's classes in source order; #id becomes the id attribute. Semantic variants are data- attributes. A renderer emits the accessibility information it can derive without the author writing anything — a table header cell takes scope="col", a callout takes role="note" named by its title, a tab panel is named by its label — and emits none that it cannot keep true. Block children are separated by newlines exactly as the CommonMark reference output does, and a non-empty document ends with a newline. Raw HTML in the source is never passed through. A directive that failed validation renders its content with no wrapper.
Normalization. The parser replaces each generic directive with a typed construct node whose type is the construct name, with attributes decoded into typed fields (§7 shows the shape). Every directive-based construct carries id (string or null) and classes (array) from its attribute specifier. An unknown attribute key is the warning DIRECTIVE_UNKNOWN_ATTRIBUTE and is dropped; a value that fails to parse is the error DIRECTIVE_INVALID_ATTRIBUTE and the default is used. Boolean attributes are written key=true or key=false. A directive whose content fails validation is left as a generic directive node so nothing is lost, and the construct's content error is reported. Callouts have no attribute specifier.
4.1 callout
Adopts GitHub/Obsidian alert syntax unchanged rather than introducing a directive. This is the single most widely rendered rich-Markdown construct in existence; competing with it would be pure loss.
> [!WARNING] Breaking change in v3
> The `render()` signature changed.
> [!NOTE]-
> Collapsed by default.
-
Types:
NOTE,TIP,IMPORTANT,WARNING,CAUTION. Case-insensitive on input, normalized to uppercase. -
Optional title follows the marker on the same line.
-
A trailing
-marks the callout collapsed by default;+marks it expanded. Both are optional. -
The bare marker is GitHub's alert syntax and renders natively there. The title and the fold suffix are Obsidian's extension; on GitHub a callout using either degrades to a blockquote whose first line shows the marker, which the §3 contract permits. Authors who want native GitHub alerts use the bare marker and put the title in the body.
-
The marker must open the blockquote's first paragraph and be followed by whitespace, a line ending, or the end of the paragraph.
[!NOTE]Titleis not a marker. A blockquote whose marker names an unknown type is the errorCALLOUT_UNKNOWN_TYPEand stays a blockquote. -
The title is the inline content on the marker's line. Content on the following lines of the same paragraph begins the body.
AST: { "type": "callout", "kind": "WARNING", "title": [<inline>...] | null, "fold": "closed" | "open" | null, "children": [<block>...] }.
HTML: <div class="ms-callout" data-type="warning" role="note" aria-labelledby="<title id>"> containing <div class="ms-callout-title" id="<title id>"> and <div class="ms-callout-body">. The title id is the callout's own id with -title appended, or ms-callout-<n>-title numbered per document. A callout is ancillary to the main content, which is what note says, and its title is its name. When a fold indicator is present, emit <details class="ms-callout"> with a <summary class="ms-callout-title"> instead, open for +, so folding works without JavaScript; the folding form takes no role, because on a <details> a role would replace the disclosure semantics, which tell a reader something more useful. A callout without a title shows its type name, title-cased, as the title.
Downgrade: a blockquote whose first paragraph is **Warning:** <title> (the type name, title-cased, followed by the title if any), followed by the body blocks. The fold indicator is dropped.
4.2 card
:::card[Pricing tiers]{tone=info}
Content here.
:::
| Attribute | Values | Default |
|---|---|---|
tone |
neutral info success warn danger |
neutral |
compact |
boolean | false |
Any block content is allowed, including nothing.
AST: { "type": "card", "title": [<inline>...] | null, "tone": "info", "compact": false, "id": null, "classes": [], "children": [<block>...] }.
HTML: <section class="ms-card" data-tone="info"> with an optional <h3 class="ms-card-title"> first. compact=true adds a bare data-compact attribute.
Downgrade: the argument becomes a heading one level below the current section depth, capped at six; the body follows as ordinary blocks. The current section depth is that of the most recent heading in the output, including headings produced by earlier cards and tabs, and zero before any heading. A card without a title contributes only its body.
4.3 grid
A list whose items become cards.
:::grid{cols=3 gap=md}
- **Fast** — sub-50 ms cold start
- **Small** — 4 kB gzipped
- **Typed** — no `any` in the public API
:::
| Attribute | Values | Default |
|---|---|---|
cols |
1–4 |
2 |
gap |
sm md lg |
md |
The content must be exactly one list, ordered or unordered. Any other content, including no content, is the error GRID_CONTENT — this is what guarantees the degradation. Renderers reduce cols responsively at narrow widths; the exact breakpoints are implementation-defined.
AST: { "type": "grid", "cols": 3, "gap": "md", "id": null, "classes": [], "children": [<list>] }. The list is kept as-is; renderers treat its items as the grid items.
HTML: <div class="ms-grid" data-cols="3" data-gap="md"> with one <div class="ms-grid-item"> per list item, holding that item's blocks (a tight item's text is wrapped in <p>). An item's own attributes (§2.5) go on its ms-grid-item.
Downgrade: the list, unchanged.
4.4 columns and ::col
::::columns{ratio="2:1"}
The main argument, at full paragraph length.
::col
:::card{tone=neutral}
A supporting sidebar.
:::
::::
| Attribute | Values | Default |
|---|---|---|
ratio |
colon-separated integers, one per column | equal |
gap |
sm md lg |
md |
The first column is implicit — content before the first ::col belongs to it. If ratio is present, its term count must equal the column count; otherwise it is the error COLUMNS_RATIO_MISMATCH and the ratio is dropped. A columns with no ::col, including an empty one, is the warning COLUMNS_SINGLE. A ::col may carry #id and .class; other keys warn.
AST: { "type": "columns", "ratio": [2, 1] | null, "gap": "md", "id": null, "classes": [], "children": [{ "type": "column", "id": null, "classes": [], "children": [<block>...] }...] }.
HTML: <div class="ms-columns" data-gap="md" style="--ms-ratio: 2fr 1fr"> with <div class="ms-column"> children. The ratio is emitted as a custom property in fr units, ready for grid-template-columns, because it is genuinely per-instance data; figure width (§4.8) is the only other place a style attribute appears.
Downgrade: columns emitted sequentially, separated by nothing. Reading order is the source order, which is why authors must put the primary column first.
4.5 tabs
Headings become tab labels.
:::tabs
### macOS
`brew install markset`
### Windows
`winget install markset`
:::
- Direct children must be headings at a single consistent level, each followed by its content. The level is set by the first heading; deeper headings are ordinary content of their tab, and a shallower heading is the error
TABS_MIXED_LEVELS. - Content before the first heading is the error
TABS_CONTENT_BEFORE_HEADING; content with no heading at all, including no content, is the errorTABS_NO_HEADINGS. - The first tab is active by default.
active=<n>(1-based, within range) overrides. - Tab state uses radio inputs, not JavaScript.
AST: { "type": "tabs", "active": 1, "id": null, "classes": [], "children": [{ "type": "tab", "depth": 3, "label": [<inline>...], "children": [<block>...] }...] }.
HTML: <div class="ms-tabs"> containing, in order: one <input class="ms-tab-input" type="radio"> per tab sharing a name, the active one checked; a <div class="ms-tablist"> of <label class="ms-tab" id=... for=...> elements; and one <div class="ms-tabpanel" data-index="n" aria-labelledby="<label id>"> per tab. The shared name is the tabs' id when given, else ms-tabs-<n> numbered per document; input ids append -<index> and label ids append -label to the input id.
No tab roles, deliberately. The ARIA tablist pattern requires aria-selected to follow the active tab. That is dynamic state, it cannot be maintained without a script, and a tablist whose aria-selected is frozen at its initial value tells assistive technology something false — worse than radio buttons, which are at least honestly described. Invariant 4 puts a script out of reach, so the roles stay off and each panel is named after its label instead. Inactive panels are hidden with display: none, which removes them from the accessibility tree, so only the visible panel is announced.
Downgrade: the headings and their content, unchanged. This is the cleanest degradation in the vocabulary and the reason tabs use headings rather than a ::tab separator.
4.6 steps
:::steps
1. Install the CLI.
2. Run `markset init`.
3. Edit `markset.toml`.
:::
Content must be exactly one ordered list; anything else is the error STEPS_CONTENT. Nested blocks inside list items are permitted.
AST: { "type": "steps", "id": null, "classes": [], "children": [<list>] }.
HTML: <ol class="ms-steps"> with <li class="ms-step">, keeping the list's start when it is not 1 and rendering tight and loose items as CommonMark does. An item's own attributes (§2.5) go on its ms-step.
Downgrade: the list, unchanged.
4.7 metrics
:::metrics
| Metric | Value | Δ |
|---------|-------|-------|
| Revenue | $4.2M | +12% |
| Churn | 2.1% | -0.4% |
:::
Content must be exactly one table with at least two columns; anything else is the error METRICS_CONTENT. Column roles are positional: label, value, and an optional delta. A delta cell beginning with + or - gets a direction attribute; direction=inverse flips which sign reads as positive, for metrics like churn where down is good.
| Attribute | Values | Default |
|---|---|---|
direction |
normal inverse |
normal |
AST: { "type": "metrics", "direction": "normal", "id": null, "classes": [], "children": [<table>] }.
HTML: <div class="ms-metrics"> with one <div class="ms-metric"> per body row (the header row is not rendered), each holding <div class="ms-metric-label">, <div class="ms-metric-value">, and, when the row has a third cell, <div class="ms-metric-delta">. data-direction="up|down" is set on the metric only when the delta begins with + or -, after applying direction=inverse.
Downgrade: the table, unchanged.
4.8 figure
:::figure[Request lifecycle from ingress to response]{#fig-lifecycle width=60%}

:::
The argument is the caption. width accepts a percentage from 1% to 100% only — no pixels, no absolute units, because the same source has to typeset to print. Content must be exactly one block: a paragraph holding a single image, a table, or a code block. Anything else is the error FIGURE_CONTENT.
AST: { "type": "figure", "caption": [<inline>...] | null, "width": 60 | null, "id": "fig-lifecycle", "classes": [], "children": [<block>] }.
HTML: <figure class="ms-figure" id="fig-lifecycle" style="--ms-width: 60%"> with <figcaption>. An image paragraph is unwrapped so the <img> is a direct child; the width custom property is omitted when width is not set. When the content is a table the caption is emitted as the table's own <caption>, its first child, rather than a sibling <figcaption>: <caption> is the table's accessible name and is announced as one, and a caption beside a table is not. Renderers are expected to place it visually below the table.
Downgrade: the content followed by an italic caption paragraph, or the content alone when there is no caption.
5. Reserved classes
Available on spans (§2.2), on directives through their specifier (§2.3), and on any other block through an attribute line (§2.5). These are the reason v0 needs no directives for typographic variation. A reserved class used outside its "Applies to" column is the warning CLASS_MISAPPLIED.
| Class | Applies to | Meaning |
|---|---|---|
.lead |
paragraph | Opening paragraph, larger |
.small |
span, paragraph | De-emphasized fine print |
.eyebrow |
paragraph | Short label above a heading |
.badge |
span | Inline pill |
.muted |
any | Reduced emphasis |
.info .success .warn .danger .neutral |
any | Tone, equivalent to tone= |
Themes may define additional classes. Unknown classes pass through to HTML without error.
6. Theme tokens
Document-level design intent lives in frontmatter. Token names are normative; how a renderer maps them to output is implementation-defined.
---
markset: 0
theme:
preset: editorial # editorial | technical | deck | report
accent: "#2563eb"
density: comfortable # compact | comfortable | spacious
radius: md # none | sm | md | lg
type:
body: "Source Serif 4"
heading: "Inter"
scale: 1.25
---
The markset: key declares the spec version and is the recommended trigger for treating a .md file as Markset. A document with no theme block must still render well; presets carry the load and per-token overrides are the exception.
Grammar. A document that begins with a --- line has YAML frontmatter up to the next --- line. This is the one place Markset departs from CommonMark, where those lines would be a thematic break; it matches GitHub's file view, Jekyll, Hugo, and Pandoc. Contexts without frontmatter support, such as GitHub comments, show the block as a heading or paragraph; nothing is lost. Frontmatter is read as a mapping of block maps, sequences, and scalars; anchors, tags, and flow maps are not interpreted. Keys other than markset and theme belong to the document and are never validated.
| Token | Values |
|---|---|
markset |
0 |
theme.preset |
editorial technical deck report |
theme.accent |
hex color, #rgb or #rrggbb |
theme.density |
compact comfortable spacious |
theme.radius |
none sm md lg |
theme.type.body, theme.type.heading |
font family name |
theme.type.scale |
number from 1 to 2 |
Theme stylesheets. Author classes on spans, blocks, and list items are the document's half of a contract whose other half is a stylesheet. No token names that stylesheet: a document that pointed at one would render differently depending on whether the file traveled with it, which is the portability failure Markset exists to avoid. The stylesheet is chosen where the document is rendered. A renderer that produces pages should accept a theme stylesheet and apply it after its default styles; the reference CLI does so with markset html --theme <file>. A document still renders acceptably without its theme, because the constructs are styled by the default stylesheet and unknown classes are inert.
Color scheme. There is no theme token for light or dark, and there will not be one. Which colors a reader sees is not authorial intent, so a document that forced a scheme would be making a decision that belongs to the reader — invariant 1 rules it out for the same reason it rules out a hex value in source. A renderer, or the chrome around a document, may still force one on the reader's behalf: the default stylesheet resolves every color through color-scheme, and data-scheme="light" or data-scheme="dark" on <body> sets it. With no attribute the document follows the reader's system preference. Printing forces light regardless.
Parse result. The frontmatter block stays in the tree as a yaml node with its raw text. When present, the root node also carries frontmatter: { "markset": 0 | null, "theme": <theme> | null }, where <theme> has every token from the table with null for those not set. Absent frontmatter leaves the root without the field.
Diagnostics.
| Code | Severity | Trigger |
|---|---|---|
FRONTMATTER_UNPARSEABLE |
warning | The block is not readable as YAML in the supported subset; markset and theme are treated as absent |
DOCUMENT_VERSION_UNSUPPORTED |
error | markset is present with a value other than 0 |
THEME_UNKNOWN_TOKEN |
warning | A key under theme or theme.type that is not in the table; it is ignored |
THEME_INVALID_TOKEN |
error | A token value outside its allowed set; the token is left unset |
7. Conformance suite
One JSON file per section under tests/, mirroring the CommonMark spec test layout, with two extra fields. Each file is an array of cases. The normative schema is spec/conformance.schema.json; the harness validates every file against it before running anything.
{
"section": "grid",
"markset": ":::grid{cols=2}\n- One\n- Two\n:::\n",
"html": "<div class=\"ms-grid\" data-cols=\"2\">...</div>\n",
"downgrade": "- One\n- Two\n",
"ast": { "type": "root", "children": [
{ "type": "grid", "cols": 2, "gap": "md", "id": null, "classes": [],
"children": [ { "type": "list", "ordered": false, "start": null, "spread": false, "children": [] } ] }
] },
"valid": true
}
valid: false cases carry a diagnostics array naming the expected error codes. Validation failures are a first-class part of the suite, not an afterthought — a closed vocabulary is only worth having if the errors are specified.
Field semantics:
sectionmust equal the file's basename.nameis an optional human label.marksetis a full document for construct sections (§4), forbracketed-span,attribute-line, andfrontmatter. For the other grammar sections (§2), it is the bare fragment — forattribute-specifier, the specifier alone; forblock-directiveandseparator-directive, a single line without its newline — andhtmlanddowngradeare omitted because a fragment has no rendering. The parser must consume the entire fragment. In the line sections,"ast": nullasserts that the line is ordinary text, and amarksetcontaining a newline is instead a full document exercising the block-level rules, withastas the mdast tree.diagnosticslists every code the parser must emit, errors and warnings alike, in any order. Absent means none are permitted.validis false exactly when at least one listed code is an error.astis compared structurally, ignoring object key order and omittingpositionfields. It is required for valid cases and optional for invalid ones, where it pins recovery behavior.- A case may carry
htmlordowngradebefore the corresponding renderer exists; the harness reports those aspects as skipped, not failed, so cases can be written ahead of the code. - The harness also grades a
naiveaspect on document cases (§3); it is computed, not written into the case.
Diagnostic codes are UPPER_SNAKE. The first segment names the spec area (ATTR_, DIRECTIVE_, GRID_...). Each diagnostic has a severity of error or warning; a document is valid when it has no errors.
What is normative. The suite has two tiers.
- Structure, which every conforming implementation must match exactly:
valid,diagnostics(the set of codes; messages and offsets are implementation-defined),ast(structurally, after removingposition), and the naive-output check. - Rendering, where the
htmlanddowngradestrings are reference output produced by the reference implementation. A conforming renderer must be equivalent, not byte-identical. HTML is equivalent when both parse to the same DOM after dropping whitespace-only text between block-level elements, with attribute order, quoting, and entity encoding ignored. Downgrade output is equivalent when both parse to the same CommonMark tree with GFM tables and frontmatter enabled, so escaping and table padding may differ. Byte-identical output is required only of the reference implementation, whose harness compares strings.
Every construct in §4 needs, at minimum: a canonical case, a nested case, an empty-content case, a wrong-content-type case (valid: false), and an unclosed-fence case.
8. Deferred, with reasons
Named here so they don't get relitigated during v0. The test each one was measured against: a need justifies a construct only when it cannot be met by a class, because it requires independent renderers to agree on structure rather than on appearance. A class is portable in syntax and not in meaning, which is the trade a construct exists to make; docs/future-requirements.md carries the worked cases.
chart— the most differentiating feature and the most work. Needs a data format decision, a rendering library, and a print story. Ship it as v0.2, designed but not implemented, so the fenced-code syntax is reserved now.timeline— degrades to a list, same assteps, and overlaps enough that shipping both invites confusion. Wait for demand.aside(margin notes) — trivial in HTML, genuinely hard in paginated output, which is where it matters most.pagebreak— belongs with a print-layout section that doesn't exist yet.- Cross-references and auto-numbering — a real need and a much larger design surface: reference resolution, numbering schemes, and a link-resolution pass. Deserves its own spec.
- Inline directives — bracketed spans plus reserved classes cover every v0 case. Adding them later is backward-compatible; removing them would not be.
- Interactivity — out of scope by invariant 4, permanently for the core spec.
9. Decisions on open questions
Recorded 2026-09-14 after the reference implementation reached the end of v0.
- Should
gridaccept nested:::cardblocks? No. The one-list rule is what makes grid's downgrade trivially exact and its naive output safe, and both are now checked mechanically. Multi-block layouts usecolumns. - Synchronized tab groups? Not in v0. Radio inputs cannot synchronize across groups without JavaScript, which invariant 4 rules out. Revisit only alongside an optional scripting layer.
- Activation trigger. Declared, not required. The parser treats every input as Markset and never refuses a document for its frontmatter. Editors, build integrations, and directory-scanning tools treat a
.mdfile as Markset only whenmarkset:is present, which protects files that use:::for something else. - Round-tripping through the downgrade renderer? No. Downgrade is lossy and clean: its output is a fixed point (downgrading it again changes nothing) and parses with no diagnostics. HTML comments would forfeit that, clutter naive rendering, and open an escaping problem. The source file is the round-trip path.
- Block attributes. Adopted as the attribute line in §2.5. Heading-trailing attributes in the Pandoc style are not part of v0; they can be added with the cross-reference spec (§8), backward compatibly.
- Attributes on one list item. Adopted 2026-09-14 as the leading attribute line of a list item (§2.5), so a single grid item or step can be singled out. The alternative, a specifier trailing the item's first line, was rejected because it would collide with bracketed spans and with ordinary text that ends in braces.
- Where a theme stylesheet is named. By the renderer, not the document (§6). A
theme.stylesheettoken was considered and rejected: a document must not depend on a file traveling with it.