Cutdown Markup Language Specification
- Status: Draft
- Version: 0.10.0
- Date: 2026-09-02
- Inspired by: CommonMark, Djot, Carve
- Versioning policy:
policies/versioning-policy.md - Change publication policy:
policies/change-publication-policy.md - Conformance policy:
policies/conformance-policy.md - Parser profile policy:
policies/parser-profile-policy.md - Diagnostics policy:
policies/diagnostics-policy.md - Diagnostic code registry policy:
policies/diagnostic-code-registry-policy.md - Diagnostic code registry:
policies/diagnostic-code-registry.md - Capability policy:
policies/capability-policy.md - Canonical serialization policy:
policies/canonical-serialization-policy.md - Compatibility fallback policy:
policies/compatibility-fallback-policy.md - Conformance corpus governance:
policies/conformance-corpus-governance.md - Decision authority policy:
policies/decision-authority-policy.md - Profile source policy:
policies/profile-source-policy.md - Compliance levels policy:
policies/compliance-levels-policy.md - Compliance evidence freshness policy:
policies/compliance-evidence-freshness-policy.md - Compliance failure response policy:
policies/compliance-failure-response-policy.md - Cross-implementation validation policy:
policies/cross-implementation-validation-policy.md - Reference parser status policy:
policies/reference-parser-status-policy.md - Governance review policy:
policies/governance-review-policy.md
Abstract
Cutdown is a lightweight markup language with a closed, finite syntax, parsed in a single forward pass so a document renders as it arrives rather than after it ends. Two identical characters wrap inline text; three open a block. Thus, the parser resolves every construct when it is first encountered.
Cutdown prioritizes unambiguous parsing, consistency, and implementability. Every syntactic construct is locally deterministic. The parser never backtracks.
It has no canonical HTML output — consuming applications interpret and render the AST.
Table of Contents
- Conventions — Identifier charset, Segment, Block type, Inline type
- Comments —
##line comment (Block.Reflection), CommentBlock (###) - Document Model — Document, Page
- Block Segments — Paragraph, Section, Meta, CodeBlock, MathBlock, QuoteBlock, List, ListItem, TaskItem, Table, ImageBlock, PageBreaker, FileRef, FileRefGroup, NamedBlock, RefDefinition, SpoilerBlock, CommentBlock
- Inline Segments — Text, Emphasis, Strong, Highlight, Spoiler, CodeInline, LineBreak, Link, ImageInline, Mark, MathInline, Variable, QuoteInline
- Universal Attributes
- Input Interpretation
- Escaping
- Parsing Algorithm
- Block Structure and Block Boundaries
- Precedence Rules
- Whitespace Rules
- Special Character Reference
- AST Node (Segment) Reference
- Name and Compliance
- Streaming Conformance Profile — decoded-character snapshots, end-of-block semantics, and profile evidence
- Canonical Form — the one spelling a writer emits where the grammar accepts several
1Conventions
The keywords MUST, MUST NOT, REQUIRED, SHOULD, MAY are used as defined in [RFC 2119].
Examples are shown as:
Input:
<cutdown source>
AST:
<segment representation>
The arrow symbol (→) separates a construct or condition from what it produces. In examples, the left side is Cutdown source and the right side is the AST notation defined in §14.
Naming characters. A character used as a noun is named on first use in a numbered section, symbol first and name in parentheses — "a single # (octothorpe)", "the ^ (caret)" — and thereafter by symbol alone. One naming per section per character; do not repeat it. Multi-character markers (##, ~~~, :::) are constructs, not characters, and are named by construct name. §13 is the register of names. This convention applies to the numbered sections only; SYNTAX.md and README.md are exempt.
1.1Streaming terms
A Unicode scalar value is a Unicode code point other than a surrogate code point. It is the character unit used by the streaming conformance profile (§16). This does not change source locations: loc offsets remain UTF-16 code-unit offsets.
An input snapshot is the complete decoded Cutdown source available to a parser at one instant. A parser MUST treat every input snapshot as an ordinary Cutdown document, including a snapshot that ends within unfinished markup.
An end of block is a boundary inferred by the ordinary Cutdown grammar: a completed block boundary, a closing fence, or end of input. It is not a token or event in Cutdown source.
1.2Identifier Character Set
Throughout this spec, ID_LITERAL refers to the following ASCII character class:
ID_LITERAL = [a-zA-Z0-9._-]
This charset is used for all identifier-like tokens: block names, mark names, code language tags, reference definition IDs, and variable keys. It is ASCII-only. Matching against ID_LITERAL is case-sensitive everywhere in this specification.
PATH_LITERAL extends ID_LITERAL with the forward-slash character:
PATH_LITERAL = [a-zA-Z0-9._/-]
PATH_LITERAL is used for path-like values: page link targets, tag link targets, and file reference paths.
1.3Segment
A segment is any node in the Cutdown AST — Block or Inline. This term is used throughout the spec when no distinction between block and inline is needed.
1.4Block Type
A Block segment is any node that occupies a full line-level slot in the document.
Block =
| Paragraph
| Section
| CodeBlock
| MathBlock
| QuoteBlock
| List
| Table
| ImageBlock
| FileRef
| FileRefGroup
| NamedBlock
| SpoilerBlock
| CommentBlock
| RefDefinition
Container blocks carry a children array — Block[] for block containers (Section, QuoteBlock, NamedBlock, SpoilerBlock), Inline[] for blocks whose content is inline (Paragraph, RefDefinition). Leaf blocks carry no children. Most blocks carry attributes: Attribute[].
1.5Inline Type
An Inline segment is any node parsed within inline content.
Inline =
| Text
| Emphasis
| Strong
| Highlight
| Spoiler
| Link
| CodeInline
| MathInline
| QuoteInline
| ImageInline
| Mark
| LineBreak
| Variable
Container inlines carry children: Inline[]. Leaf inlines carry no children. Most inline nodes carry attributes: Attribute[].
Wherever an AST node carries Inline[], the content was produced by the inline parsing rules (§5). All inline contexts are explicitly marked "parsed by inline rules."
1.6Block Scope
A block scope is one flat sequence of sibling blocks. A document has one block scope at its root, and one inside the child list of every block container (ListItem, TaskItem, QuoteBlock, NamedBlock, SpoilerBlock).
A rule described as resolved within the current block scope looks only at that sequence and never past its container boundary. This applies to reflection attachment (§2.2), caption binding (§6.2), and the sectionization fold (§9.5.1).
2Comments
Cutdown has two comment constructs that share the # (octothorpe) symbol and follow the doubled/tripled-delimiter rule (§10.4). ## stores comment payloads as Reflection entries on blocks; ### (CommentBlock) produces a separate AST segment. Both are hidden from rendering by default.
2.1Single # is literal
A single # is literal text in all positions. There is no whitespace rule, no line-start rule, no special treatment.
# foo → Paragraph([Text("# foo")])
foo # bar → Paragraph([Text("foo # bar")])
2.2Double octothorpe ## — Line Comment (Reflection)
A double ## opens a line comment that runs to the end of the line. It is recognized at line-start AND mid-line.
## a whole-line comment
foo ## trailing comment
- The
##and everything up to (but not including) the next\nis the comment payload. The##and one leading space (if present) are stripped; the remainder is thetextvalue verbatim — trailing whitespace inside the payload is preserved, since the payload is opaque (§12's trailing-space rule does not reach inside it). - Opaque to all other delimiters. Once
##is recognized, the parser consumes characters to\nblindly. It does NOT honour link-text](right bracket), table cell|(pipe), attribute}(right brace), or any other inline construct's closer. An unclosed opener before##degrades to literal per §9.4. ##boundaries are detected during Phase 2 preprocessing (§9.2), before block classification. Block classification operates on the pre-##substring of each line.##is not recognized inside opaque block contexts:CodeBlock,MathBlock,Metacontent, orCommentBlockcontent. For those blocks only the opener line (the first line of the fence) and the closer line (the closing fence) are scanned.##is not recognized inside inline opaque contexts:CodeInline,MathInline, and quoted attribute values.- A literal
##in normal text is written\##or#\#(§8).
## does not produce an AST segment. Instead, the comment payload is stored as a Reflection entry on the nearest enclosing block:
interface Reflection {
loc: Loc // source range of the ## payload (raw-file UTF-16 offsets, §14)
text: string // payload after ##, one leading space stripped
}
Every block type carries reflection: Reflection[] | null (null when no ## is present). See §14 for the full list.
Trailing inline ## (on a structural line)
When ## appears after content on a line that belongs to a block, the payload is recorded with the loc of that payload's source range.
Bubbling rule. When the structural line belongs to a child of a container, the payload bubbles to the outermost container at that scope level:
| Structural line | reflection attaches to |
|---|---|
| Table row line | Table (not Row) |
| List item line | List (not ListItem) |
| NamedBlock / QuoteBlock / FileRefGroup opener | that container |
Standalone ## comment line
A line whose pre-## content is empty or whitespace is a standalone comment line. It acts as a blank line for block-boundary purposes (§9.2).
- Attaches to the immediately preceding structural block in the current scope, carrying its own
loc. Consecutive standalone comments accumulate in source order. - Closes any active accumulation (continuing Paragraph, open FileRefGroup) before attaching.
- Orphan — no preceding structural block in the current scope: produces
Paragraph { children: [], reflection: [{ loc, text }] }. Multiple consecutive orphan lines fold into one empty Paragraph. - Inside a container body (NamedBlock, QuoteBlock, ListItem, etc.) the same rule applies within that block scope (§1.6): it attaches to the preceding sibling block in that scope.
Examples:
## foo → no segment; preceding block gains { loc, text: "foo" }
foo ## bar → Text("foo ") — block gains { loc, text: "bar" }
``code ## not`` → CodeInline { value: "code ## not" }
\## foo → Text("## foo")
### at inline → reflection entry; text: "# at inline"
[text ## comment](url)
→ Text("[text ") (the [ has no ] closer before the ## cut; the verbatim slice runs to the cut, §9.4.1)
Paragraph.reflection += { loc, text: "comment](url)" }
| Head ## comment | Next |
→ pre-## `| Head ` fails row grammar (no closing |).
Block becomes Paragraph([Text("| Head ")]).
Paragraph.reflection += { loc, text: "comment | Next |" }
| AA | BB | ## row comment
→ pre-## `| AA | BB |` is a valid 2-cell row.
Table.reflection += { loc, text: "row comment" }
Orphan:
Input:
## note with no preceding block
AST:
- type: Paragraph
children: []
reflection:
- loc: { start: 3, end: 31 }
text: note with no preceding block
2.3Triple octothorpe ### — CommentBlock (block comment)
### opens a block comment that runs until the next bare ###, or end of document.
###
any content
including blank lines, =headings, **markup**, ``code`` —
all captured as a single opaque string
###
- The opener line MUST be exactly
###with no name and no attributes (no[name], no{attrs}are recognized). - The closer is the next line whose stripped content is exactly
###. Indentation is not compared — the rule is the same as for every other fence (§10.4.2). To keep a###line inside the body, escape it (§8.3). - Content between opener and closer is opaque — captured as a raw string with no inline or block parsing. The
\nbetween content lines is preserved; a single trailing\nis appended. ###is recognized at Page scope AND inside containers (ListItem,TaskItem,QuoteBlock,NamedBlock,SpoilerBlock). Container indentation is stripped before classification (§10.2), so the fence is recognized wherever the container's content begins.- Unclosed
###consumes to end-of-document and emits aCommentBlockwith the captured content → warning CDN-0006. The opaque content gives the parser no way to observe container boundaries from inside the comment — the same rule applies to all opaque fences (CodeBlock, Meta, MathBlock). ###inside an openCodeBlock,MathBlock, orMetabody is literal content (opaque siblings win).- Closer escape:
\#inside the body emits a literal#. A line\###,#\##, or##\#therefore does NOT close the fence. All other\Xis literal (including\\→ two chars). See §8.3.
AST type:
interface CommentBlock {
type: "CommentBlock"
text: string // content between opener and closer; lines joined with `\n`; single trailing `\n`
}
Examples:
Input:
###
draft note
TODO: revise
###
AST:
CommentBlock { text: "draft note\nTODO: revise\n" }
Input:
- item one
###
todo
###
- item two
AST:
List
├── ListItem [Text("item one"), CommentBlock { text: "todo\n" }]
└── ListItem [Text("item two")]
2.4Page assembly
Only a Meta and a PageBreaker create Page boundaries.
2.5Render policy
Reflection entries and CommentBlock nodes are hidden by default. Conforming renderers (HTML, PDF, markdown round-trip) SHOULD omit them. Tooling renderers (formatter, IDE preview, comment-thread plumbing) MAY read and display them. The default is normative; the opt-in is implementation-defined.
2.6Interaction with attributes
## is transparent to attribute resolution. Because ## payloads are stored in reflection rather than in the inline stream, no scope-chain slot is consumed and no "orphan-due-to-comment" condition arises. Attributes bind exactly as if the ## were not present.
== Heading **bb** {.a}{.b} ## trailing comment
{.a}adjacent to**bb**→ attaches to Strong.{.b}is the last{...}on the heading line → claims Section.## trailing comment→Section.reflection += { loc, text: "trailing comment" }.
Result:
Section({class:"b"}, level=2,
heading: [
Text("Heading "),
Strong({class:"a"}, [Text("bb")])
],
reflection: [{ loc, text: "trailing comment" }]
)
3Document Model
Cutdown introduces Document, Page, Section, and Block segments to represent the logical structure of a document. The parser produces a tree of these segments, which is then consumed by renderers or other tools.
A Cutdown document is a tree rooted at a Document segment. Every file produces at least one Page, even if empty. Every file produce exactly one Document segment, even if multiple pages or multiple transclusions are present.
3.1Document
The root node of every Cutdown file. Produced automatically — there is no explicit document syntax.
AST type:
interface Document {
type: "Document"
children: Page[]
}
3.2Page
A logical division within a document. Every document has at least one Page.
AST type:
interface Page {
type: "Page"
meta: Meta | null
children: Block[]
}
Pages are not parsed — they are derived from the root block sequence by the pagination fold, which is defined in §9.5.2. Two constructs drive it: Meta blocks (§4.3) and PageBreakers (§4.10).
A Ghost Page is a Page with meta: null and children: []. Ghost Pages are valid and emitted as-is; consumers decide how to handle them.
4Block Segments
4.1Paragraph
Syntax: Any non-blank lines that do not match another block construct.
First paragraph line 1
Second paragraph line 1.
Second paragraph line 2.
A contiguous run of non-blank lines not matched by any other block type.
No block opens inside a paragraph. Once a run has been classified as a Paragraph, it continues to the next blank line (or the end of the enclosing container, or end of input). Every later line in the run is paragraph content regardless of how it begins — a heading marker, a list marker, a --- separator, a table row, and every fence opener (```, ~~~, $$$, ^^^, :::, ###) alike. No block opener is an exception, and none emits a diagnostic: to open a block, put a blank line before it.
Two line forms are not block openers and are unaffected by this rule — they attach to the preceding block instead of starting one, and may follow a paragraph line directly:
Input:
Some text
```js
const x = 1
```
AST:
Paragraph { children: [Text("Some text```jsconst x = 1```")] }
The fence opener is ordinary text, so no CodeBlock forms. Soft breaks fold to zero (§12.1), which is why the lines concatenate with no separator inserted.
AST type:
interface Paragraph {
type: "Paragraph"
children: Inline[]
attributes: Attribute[]
reflection: Reflection[] | null
}
- All lines are parsed by inline rules and concatenated. Result is
Inline[]. - A single newline between lines is a soft break — folded to zero; lines concatenate directly with no character emitted.
- Trailing spaces before the newline collapse to a single space, preserved as
Text(" ")(explicit word boundary). At a block boundary the space is dropped. See §12. - A
\(backslash) at line end produces aLineBreaksegment (explicit line break).
Example:
Input:
First line
second line\
third line
AST:
Paragraph
├── Text("First linesecond line")
├── LineBreak
└── Text("third line")
4.2Section (Heading)
Syntax:
={n} inline-content {attrs}
A heading creates a Section segment. Consumers receive Section segments — there is no bare Heading node in the AST. A Section's extent is not parsed; it is derived by the sectionization fold defined in §9.5.1.
AST type:
interface Section {
type: "Section"
level: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
heading: Inline[]
children: Block[]
attributes: Attribute[]
reflection: Reflection[] | null
}
| Syntax | Level |
|---|---|
= Heading |
1 |
== Heading |
2 |
=== Heading |
3 |
==== Heading |
4 |
===== Heading |
5 |
====== Heading |
6 |
======= Heading |
7 |
======== Heading |
8 |
========= Heading |
9 |
- Heading content is parsed by inline rules. Result is
Inline[]. - The content continues over following lines under the paragraph continuation rules (§4.1): the run ends at a blank line, an attribute line, a standalone
##(§2.2), or a^caption line (§6.5). Soft breaks fold to zero, so= Titlefollowed bycontentis one heading readingTitlecontent(§12.1). - A heading MUST be preceded by a blank line (or be the first line of the document or block container).
- The last
{...}on the heading line is claimed by the Section (Last-Attr Rule). Earlier{...}attach to preceding inline elements. An explicit empty{}as the last token means the Section carries no attributes. - Sections nest by level. A level-2 heading inside a level-1 section creates a child section. A level-1 heading closes all open sections and opens a new one at the root.
- Sections may appear inside block containers (
ListItem,TaskItem,QuoteBlock,NamedBlock). The fold runs independently inside each container, so aSectionnever crosses a container boundary (§9.5.1). - Opener escape:
\=at line start suppresses heading formation at any level —\=,\==,\===... all becomeParagraph([Text("= ...")]). See §8.2.
Examples:
= H1 [with link](..){.class}
→ Section { level: 1, heading: [Text("H1 "), Link(...)], attributes: { class: ["class"] } }
= H1 [with link](..){.class}{}
→ {} is last token → Section carries no attributes; {.class} attaches to Link
→ Section { level: 1, heading: [Text("H1 "), Link(..., {class:"class"})], attributes: {} }
4.3Meta (Front Matter)
Syntax: Fenced with exactly three tildes.
~~~format
content
~~~
AST type:
interface Meta {
type: "Meta"
format: "yaml" | "toml" | "json" // default: "yaml"
raw: string
}
- Recognized formats:
yaml,toml,json(case-insensitive). Default:"yaml". - Content is a raw string passed as-is to the consumer. Lines joined with
\n; a single trailing\nis appended. - Fills the current
Page.meta; if that slot is already set, opens a new Page first (§9.5.2). Never appears inPage.children. - Only valid at Page scope. Inside block containers, the entire span is emitted as a
Paragraph→ warning CDN-0030. - Unclosed fence → warning CDN-0002.
- No
attributesfield. - Closer escape:
\~inside the body emits a literal~(tilde) and consumes the\. A line\~~~,~\~~, or~~\~therefore does NOT close the fence. All other\Xsequences are literal. See §8.3. Opener escape: see §8.2.
Example:
~~~
title: My Document
~~~
AST:
Page {
meta: Meta { format: "yaml", raw: "title: My Document\n" },
children: []
}
4.4CodeBlock
Syntax: Fenced with exactly three backticks.
```language {attrs}
content
```
AST type:
interface CodeBlock {
type: "CodeBlock"
language: string // default: "text"
raw: string
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
- Language identifier uses
[ID_LITERAL]+. If omitted (means equal empty string) should be made default to"text". - Content is literal — no inline parsing. Lines joined with
\n; no trailing\nappended. Blank lines preserved verbatim. - Fixed 3-backtick fence. Variable-length fences not supported. No nesting.
- Unclosed fence: content runs to end of document → warning CDN-0001.
- Legal inside
ListItem,TaskItem,QuoteBlock,NamedBlock. Container indentation is stripped from content lines. - Closer escape:
\`inside the body emits a literal`(backtick) and consumes the\. A line\```,`\, or\`therefore does NOT close the fence. All other\Xsequences are literal (including\\→ two chars). See §8.3. Opener escape: see §8.2. - Supports caption line (§6.2). A
^ textline immediately after the closing fence (no blank line) setscaption: Inline[]on this node.
4.5MathBlock
Syntax: Fenced with exactly three dollar signs.
$$$ {attrs}
\LaTeX formula
$$$
AST type:
interface MathBlock {
type: "MathBlock"
raw: string
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
- Content is literal — no inline parsing. Passed as raw string to the consumer (KaTeX or equivalent).
- Lines joined with
\n; no trailing\nappended. Blank lines preserved verbatim. - Unclosed fence: content runs to end of document → warning CDN-0003.
- Legal inside block containers. Indentation handling follows the same rules as
CodeBlock. - No closer escape — LaTeX owns
\. Every backslash inside a MathBlock body is literal, including\$. A literal$$$line inside the body therefore prematurely closes the fence; this is an accepted unsupported case (wrap such content in aCodeBlockor split the math). See §8.3. Opener escape (\$$$): see §8.2. - Supports caption line (§6.2). A
^ textline immediately after the closing fence (no blank line) setscaption: Inline[]on this node.
Example:
Input:
$$$ {.display #eq1}
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$$
AST:
MathBlock {
raw: "\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}",
attributes: [{ id: "eq1"}, {class: ["display"] }]
}
4.6QuoteBlock
Syntax: Lines prefixed with >.
> content
> more content
>> nested quote
AST type:
interface QuoteBlock {
type: "QuoteBlock"
children: Block[]
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
- The
>(greater-than sign) prefix opens the quote. It is required on the quote's first line. - Lazy continuation. A following line without
>continues the quote. The line is handed to the quote's current child block and follows that block's own continuation rules — aParagraphabsorbs it as a continuation line (§4.1), a list item absorbs it per §10.5, aSectionkeeps it in the section body. This is the same continuation behaviour the line would have outside the quote. - The quote ends at a blank line, at the end of the enclosing block container, or at the end of the document. A
>line after a blank line opens a newQuoteBlock. - The
>prefix and one optional following space are stripped. Content is parsed as full block content. - Nesting:
>>= blockquote inside blockquote. Both>>and> >are valid. Depth = count of leading>characters. A lazy continuation line carries no>, so it does not change the current depth — it continues the innermost open quote. - Body edge-blank trim: After
>stripping, leading and trailing blank lines inside the quoted body are stripped before children are parsed. See §10.6. - Opener escape:
\>at line start →Paragraph([Text("> ...")]). See §8.2. - Supports caption line (§6.2). A
^ textline immediately after the closing line (no blank line) setscaption: Inline[]on this node.
Examples:
Input:
> Line 1
> Line 2
> Line 3
AST:
QuoteBlock
└── Paragraph { children: [Text("Line 1Line 2Line 3")] }
Input:
> Line 1
>>> Line 2
>> Line 3
AST:
QuoteBlock
├── Paragraph { children: [Text("Line 1")] }
└── QuoteBlock
├── QuoteBlock
| └──── Paragraph { children: [Text("Line 2")] }
└── Paragraph { children: [Text("Line 3")] }
Input:
> Line 1
Line 2
Line 3
Line 4
AST:
QuoteBlock
└─── Paragraph { children: [Text("Line 1Line 2Line 3")] }
Paragraph { children: [Text("Line 4")] }
Input:
> - item one
still item one
AST:
QuoteBlock
└─── List { kind: "bullet", loose: false }
└── ListItem { children: [Text("item onestill item one")] }
4.7List
Syntax: One or more list items sharing a common indentation level.
- unordered item
- nested item
1. ordered item
2. second item
- [ ] task item
- [x] checked task
AST type:
interface List {
type: "List"
kind: "bullet" | "numbered" | "checklist"
start: number | null // first item number for kind: "numbered"; null otherwise
loose: boolean
children: (ListItem | TaskItem)[]
attributes: Attribute[]
reflection: Reflection[] | null
}
- Unordered marker:
-(hyphen) followed by one space. Only-is supported. - Ordered marker:
{number}.followed by one space. The first item's number setsList.start; every other item's number is ignored. kindis determined by the first item's marker:-→"bullet",{n}.→"numbered",- [ ]/- [x]/- [+]→"checklist".startis non-null only forkind: "numbered".- Tight vs loose: A list is
loose: truewhen a blank line appears between items within the list scope.looseis an advisory flag for consumers — the parser does not alter children based on it. - A blank line followed by a col-0 marker ends the current list and starts a new
Listsegment. - Opener escape:
\-at line start →Paragraph([Text("- ...")]). See §8.2.
4.7.1ListItem
Syntax: A list marker followed by content, with optional indented continuation lines.
- unordered item
continuation line
1. ordered item
continuation line
AST type:
interface ListItem {
type: "ListItem"
children: (Block | Inline)[]
attributes: Attribute[]
}
- Content on the marker line is parsed by inline rules. Continuation lines at the same or deeper indentation are absorbed.
- When a blank line is absorbed inside the list (loose list), content is block-promoted:
childrenbecomesBlock[](e.g.Paragraph). - Attributes follow the scope-chain rule (§6).
4.7.2TaskItem
Syntax:
- [ ] content
or
- [x] content
or
- [+] content
AST type:
interface TaskItem {
type: "TaskItem"
checked: boolean
children: (Block | Inline)[]
attributes: Attribute[]
}
- Marker:
-followed immediately by[ ](unchecked) or[x]/[X]/[+](checked), then one space and content.[]with no space is not a task marker — the item is an ordinaryListItemwhose content begins[].[+]is bidi-neutral; see §17 for which spelling a writer emits. - Only
kind: "bullet"list items may carry a checkbox. Akind: "numbered"list encountering a task marker closes and a newkind: "checklist"List segment opens. - A
Listwithkind: "checklist"haschildren: TaskItem[]exclusively;kind: "bullet"andkind: "numbered"havechildren: ListItem[]exclusively. - Mix of item types introduces a new list boundary: the first item of the new type starts a new
Listsegment. - Follows the same multiline and block-promotion rules as
ListItem.
Example:
Input:
- [ ] Buy milk
- [x] Write spec
- [+] Review draft
- plain item
AST:
List { kind: "checklist", loose: false }
├── TaskItem { checked: false, children: [Text("Buy milk")] }
├── TaskItem { checked: true, children: [Text("Write spec")] }
└── TaskItem { checked: true, children: [Text("Review draft")] }
List { kind: "bullet", loose: false }
└── ListItem { children: [Text("plain item")] }
4.8Table
A table opens with a line starting with | (pipe). Standard Markdown (GFM) pipe tables parse unchanged.
| Cell A | Cell B | ← no-header table
| Name | Score | ← table with header
|:-------|------:| ← header separator; left / right align
| Alice | 42 |
| Bob | 17 |
AST type:
interface Table {
type: "Table"
rows: Row[]
columns: Column[]
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
interface Row {
type: "Row" | "Header"
children: Cell[]
attributes: Attribute[]
}
interface Cell {
type: "Cell"
children: Inline[]
row: number // zero-indexed position in Table.rows[]
column: number // zero-indexed
}
interface Column {
type: "Column"
align: "start" | "left" | "right" | "center" | "comma" | "decimal" // default: "start"
}
Cell and Column do not carry attributes. Colspan and rowspan are not supported.
Header rows
A row whose every cell consists solely of an alignment pattern (see Column alignment below) — optionally surrounded by spaces — is a header separator. It marks the group of content rows immediately preceding it (since the previous separator row, or the start of the table) as type: "Header". All other content rows are type: "Row".
| A | B |
|---|---| ← header separator → preceding rows become type: "Header"
| C | D | ← type: "Row"
Discontiguous header sections are valid — HTML <table> supports mixed <thead>/<tbody> ordering. A header separator anywhere marks only the rows in the immediately preceding section.
Column alignment
Alignment is taken from the cell patterns of the first header separator in the table. Subsequent header separators do not update alignment.
| Pattern | Alignment |
|---|---|
:--- |
"left" |
---: |
"right" |
:---: |
"center" |
---, |
"comma" |
---. |
"decimal" |
---- |
"start" (default) |
Each pattern requires at least three - (with the optional alignment marks shown). The minimum is a deliberate guard: a content row of single-dash placeholder cells (| - |) remains content, not a header separator.
A column with no corresponding position in the header separator, and every column of a table with no header separator, takes "start".
"start" is not a synonym for "left". "start" follows the text direction of the content — the left edge in a left-to-right script, the right edge in a right-to-left one. :--- is an explicit request for the left edge in either. A consumer that renders to CSS maps "start" to text-align: start and "left" to text-align: left.
Table shape
Leading | required. Every content row and header separator opens with |. It is the detection anchor (§9.3 classifies a table by ^\|); without it the line is a Paragraph.
Trailing | optional. The last | on a line is always the closer, never a cell separator. It closes the final cell rather than opening an empty one.
| A | B | → 2 cells
| A | B → 2 cells
| A | B | | → 3 cells, the third empty, then the closer
The closer also determines how deep a trailing {attrs} chain reaches — see Attrs scope chain below and §6.
Column count is fixed by the first content row. Not max() across rows. A header separator is never a content row, so it never defines the count.
- A later row with fewer cells is padded with empty
Cellnodes to the column count. No diagnostic — nothing is lost, this is normalisation. Padded cells carry noloc(§14). - A later row with more cells has the surplus cells dropped and emits CDN-0018.
- A header separator wider than the column count likewise drops its surplus and emits CDN-0018 — losing alignment silently is the failure the diagnostic exists to prevent.
- A header separator narrower than the column count is not an error; the uncovered columns take
"start"per Column alignment above. - A table whose only rows are header separators has
columns: []and emits no diagnostic — with no content row, no column count was ever established for the separator to exceed. This is consistent with|alone yielding an empty table.
Row content
- Each
|content line is one independent logical row. - Cells contain
Inline[]parsed by full inline rules. - The table ends at the first line that is neither a content row nor a header separator — including a blank line, a container boundary, or any other block opener. That line is classified on its own (§9.3); a later
|line opens a new table.
Attrs scope chain. Rule B (§6) applies. The slots available to a row's trailing {attrs} sequence depend on two things — which row it is, and whether the last cell was closed:
| Row | Last cell | Slots, outermost first |
|---|---|---|
| last content row | left open (no trailing |) |
Table, Row, last attr-bearing inline in the cell |
| last content row | closed by a trailing | |
Table, Row |
| any earlier row | left open | Row, last attr-bearing inline in the cell |
| any earlier row | closed by a trailing | |
Row |
Cell bears no attributes, so the chain never stops at a cell — it walks past it to the inline inside. Writing the closing \| seals that cell's inline context before the chain begins, which removes the inline slot.
A chain written before the closer is inside the cell's inline context and distributes through the cell's slots instead — the cell's last attr-bearing inline, one slot only. This applies to any cell, not just the last. A cell whose content is plain text has no such inline, so the {} is dropped with CDN-0011; neither Row nor Table receives it. See §6.
The inline slot searches the last cell only. If that cell holds no attr-bearing inline, the slot goes unclaimed and the {} is dropped with CDN-0011 (§6.1.3).
{attrs} on a header separator row claim the Table slot directly.
Last content row:
| td1 | td2 | {.a}{.b} → Table({.b}, Row({.a}, ...)) ← sealed: 2 slots
| td1 | td2 | {.a} → Table({.a}, Row(...)) ← single {} = Table slot
| AA | **BB** {.a}{.b}{.c} → Table({.c}, Row({.b}, Cell(...), Cell(Strong({.a}, "BB"))))
| AA | **BB** | {.a}{.b}{.c} → Table({.c}, Row({.b}, ...)) ← sealed; {.a} dropped (CDN-0011)
| AA | CC {.a}{.b}{.c} → Table({.c}, Row({.b}, ...)) ← last cell is Text; {.a} dropped
Chain before the closer — cell chain, 1 slot:
| AA | **BB** {.a} | → Row(Cell(...), Cell(Strong({.a}, "BB")))
| **AA** {.x} | BB | → Row(Cell(Strong({.x}, "AA")), Cell("BB"))
| AA | CC {.a} | → Row(Cell("AA"), Cell("CC")) ← plain text: {.a} dropped (CDN-0011)
Mid-table row:
| td1 | td2 | {.a} → Row({.a}, ...) ← sealed: 1 slot only
| AA | **BB** {.a}{.b} → Row({.b}, Cell(...), Cell(Strong({.a}, "BB")))
Empty tables
A single | line with no cell content is a valid empty table:
| → Table { rows: [], columns: [] }
| {.tbl} → Table { rows: [], columns: [], attributes: [{class:["tbl"]}] }
For | {.tbl}: no row is present, so {.tbl} has no Row slot to claim; it falls through to the Table slot directly.
Reflection
Trailing ## comment on any table line (content row or header separator) bubbles to Table.reflection carrying the payload's loc. See §2.2. A ## appearing inside cell content (before the row's closing |) causes the pre-## cell fragment to fail the row grammar and fall back to a Paragraph; the payload then attaches to that Paragraph's reflection.
Caption and escaping
- Supports caption line (§6.2). A
^ textline immediately after the table's last line (no blank line) setscaption: Inline[]. - Escaping:
\|at line start →Paragraph(suppresses a table row or header separator). See §8.2.
4.9ImageBlock
Syntax:
 {attrs}
A line at block level beginning with ![ is classified as an ImageBlock.
AST type:
interface ImageBlock {
type: "ImageBlock"
alt: Inline[]
src: string
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
- See §5.9 for
ImageInlinesyntax and parsing details. The same rules apply toImageBlockalt text and src. - Consecutive
ImageBlocklines with no blank line between them are wrapped in aFileRefGroup { group: "image" }. ImageBlockis the block-level counterpart ofImageInline(§5). The difference is thatImageBlockmust be the only one segment on the line.- Only-segment fallback. Phase 3 classification (§9.3) is provisional: it matches
^!\[. If the line carries anything after the image, the only-segment requirement fails and the line falls back to aParagraphcontaining anImageInlinefollowed by the remaining inline content. No content is dropped.
 → ImageBlock { alt: [Text("a")], src: "b" }
 trailing text → Paragraph([ImageInline(...), Text(" trailing text")])
Cascade to watch. Paragraph is not captionable (§6.2), so a ^ line after the fallback has no captionable predecessor and becomes a Paragraph itself plus CDN-0008. One stray word after an image therefore demotes both the image and its caption to prose, with only the diagnostic to show for it.
- Supports caption line (§6.2). A
^ textline immediately after this line (no blank line) setscaption: Inline[]on this node.ImageInlinedoes not support captions — it is not a block.
4.10PageBreaker
Syntax:
---
A top-level line beginning exactly ---. A PageBreaker is a pagination signal, not a block: it is consumed by the pagination fold (§9.5.2) and produces no AST node. It unconditionally closes the current Page — as a Ghost Page if empty — opens a new one, and closes all open root-level Sections.
- The rest of the line — surplus hyphens,
{attrs}, any other content — is dropped, and CDN-0016 is emitted. There is no attributed form. - Inside a Block container (
List,QuoteBlock,NamedBlock,SpoilerBlock): a blank-line-surrounded---line is not a PageBreaker — it parses asParagraph([Text("---")])and CDN-0017 is emitted. Glued to a preceding paragraph,---is ordinary paragraph content per the no-interrupt rule (§10.1); no diagnostic is emitted. - Opener escape:
\---,-\--, or--\-at top level →Paragraph([Text("---")]); no Page boundary occurs. See §8.2. - Cutdown performs no front-matter detection: a document-leading
---is a PageBreaker like any other, yielding a leading Ghost Page. - Cutdown defines no thematic-break (horizontal-rule) element.
Examples:
--- → page boundary, no node
Illegal:
--- {.page-end} → page boundary, no node (tail dropped, CDN-0016)
--- some text → page boundary, no node (tail dropped, CDN-0016)
4.11FileRef
Syntax:
/path/to.file {attrs}
Any line beginning with / is a file reference block.
AST type:
interface FileRef {
type: "FileRef"
path: string
fragment: string | ''
query: string | ''
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
pathstarts with/(slash) and is a run ofPATH_LITERALcharacters (§1.2) extended with any other character that is not one of<>:"\|*{}or whitespace. The first space, when present, separates the path from{attrs}.- Fragment: everything from the first
#(octothorpe) to the next space (or end of line, before{attrs}) is extracted asfragment.srcstores the portion before#. - Query (
?): if path contains?, the parser extracts the query string asquery.srcstores the portion before?. fragmentandqueryare mutually independent — either, both, or neither may be present. If absent, they are set to empty string''(not null).- A line containing only
/is not aFileRef. The whole line is emitted as aParagraphof literal text. groupis set automatically by file extension (see Known Groups below). Consumers may configure the extension lists.- Opener escape:
\/pathat line start →Paragraph([Text("/path")]). See §8.2. - Supports caption line (§6.2). A
^ textline immediately after this line (no blank line) setscaption: Inline[]on this node. If theFileRefis part of an activeFileRefGroup, the^line closes the group and binds to it instead (see §4.12).
Known Groups (defaults):
| Group | Extensions |
|---|---|
image |
.png .jpg .jpeg .gif .webp .svg |
video |
.mp4 .avi .mov |
audio |
.mp3 .wav .aac .ogg |
4.12FileRefGroup
Consecutive FileRef or ImageBlock lines of the same known group with no blank line between them are automatically wrapped in a FileRefGroup. Not produced by explicit syntax — assembled during parsing.
AST type:
interface FileRefGroup {
type: "FileRefGroup"
group: "image" | "video" | "audio"
children: (FileRef | ImageBlock)[]
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
- A blank line breaks any active group.
- Different groups do not merge — two consecutive lines of different groups produce two separate
FileRefGroupsegments. - Unknown-extension files are never grouped.
- Supports caption line (§6.2). A
^ textline immediately after the last member of the group (no blank line) setscaption: Inline[]on theFileRefGroup. A^line mid-run closes the group at that point; the nextFileRef/ImageBlockstarts a new group.
Example:
Input:
/photos/a.png
/photos/b.jpg
/docs/report.pdf
AST:
FileRefGroup { group: "image" }
├── FileRef { src: "/photos/a.png", group: "image" }
└── FileRef { src: "/photos/b.jpg", group: "image" }
FileRef { src: "/docs/report.pdf", group: null }
4.13NamedBlock
Syntax:
:::block-name {attrs}
content
:::
AST type:
interface NamedBlock {
type: "NamedBlock"
name: string
children: Block[]
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
- Opening:
:::followed immediately by a block name ([ID_LITERAL]+), then optional attributes. - Closing:
:::on its own line (no name). - A
:::opener not followed immediately by anID_LITERALcharacter does NOT open a NamedBlock — classified as a Paragraph → warning CDN-0013. Exception: if the line begins with an escaped opener (\:::,:\::,::\:), no warning is emitted — the escape is a deliberate author signal. See §8.2. - Content: any block content, including nested
:::containers. - Unclosed container: content runs to end of document → warning CDN-0004.
- Indentation collapsing: The first content line establishes the base indentation. That many leading spaces are stripped from all content lines before parsing.
- Body edge-blank trim: Leading and trailing blank lines inside the body are stripped before children are parsed. See §10.6.
- Supports caption line (§6.2). A
^ textline immediately after the closing:::(no blank line) setscaption: Inline[]on this node.
Example:
:::callout {.warning}
> quoted text
:::
→ NamedBlock { name: "callout", children: [QuoteBlock([Text("quoted text")])], attributes: {class:["warning"]} }
4.14RefDefinition
Syntax:
[^ref]: content
MUST start at the beginning of a line.
AST type:
interface RefDefinition {
type: "RefDefinition"
ref: string
children: Inline[]
attributes: Attribute[]
reflection: Reflection[] | null
}
refuses[ID_LITERAL]+characters. Case-sensitive.- Empty
ref(line starting with[^]:) is invalid state and produces string literal for whole line. - Empty content (
[^ref]:) is valid and produces an emptychildrenarray. - Content is parsed by inline rules. Result is
Inline[]. - Cutdown does not validate that every
[^ref]link has a matching definition. - When the same
refis defined more than once in a document, resolution uses the last definition in source order (last wins). Resolution is the consumer's responsibility (§9.4).
4.15SpoilerBlock
Syntax: Fenced with exactly three carets.
^^^ {attrs}
content
^^^
AST type:
interface SpoilerBlock {
type: "SpoilerBlock"
children: Block[]
attributes: Attribute[]
caption: Inline[] | null
reflection: Reflection[] | null
}
- Opening:
^^^at line start, optionally followed by{attrs}. The opening line carries no other content. - Closing:
^^^on its own line. - Content is parsed as blocks — paragraphs, lists, images, and
:::NamedBlocks are all permitted. The contrast is intentional, because a Spoiler hides meaning, not structure. - No nested SpoilerBlocks. The first
^^^line encountered inside an open SpoilerBlock always closes it. A second^^^opener on the next non-blank line starts a new sibling SpoilerBlock. Tiered reveals (a Spoiler inside a Spoiler) are out of scope; if a use case genuinely requires it, wrap the inner content in:::spoilerNamedBlock instead. - Fixed 3-caret fence
^^^. Variable-length fences not supported. - Indentation collapsing: the first content line establishes the base indentation; that many leading spaces are stripped from all content lines before parsing — same rule as
NamedBlock(§4.13). - Body edge-blank trim: Leading and trailing blank lines inside the body are stripped before children are parsed. See §10.6.
- Unclosed fence: content runs to end of document (or end of the parent block container) → warning CDN-0005.
- Legal inside
ListItem,TaskItem,QuoteBlock,NamedBlock. Container indentation is stripped from content lines. - Semantic variants (NSFW, redacted, entertainment-spoiler) are carried in
attributes;SpoilerBlockhas nokindfield. - Escape: SpoilerBlock body is not opaque — children are parsed as blocks. Use §8.2 block-opener escape (
\^^^,^\^^,^^\^) on a content line to prevent it from closing the fence. - Supports caption line (§6.2). A
^ textline immediately after the closing^^^(no blank line) setscaption: Inline[]on this node.
Example:
Input:
^^^ {.nsfw}
Plot twist: __the butler__ did it.
- and so did the gardener
^^^
AST:
SpoilerBlock {
attributes: { class: ["nsfw"] },
children: [
Paragraph([Text("Plot twist: "), Emphasis([Text("the butler")]), Text(" did it.")]),
List { kind: "bullet", children: [ListItem([Text("and so did the gardener")])] }
]
}
4.16CommentBlock
Syntax: Fenced with exactly three octothorpes. See §2.3 for the full normative semantics; this section restates the block-level surface.
###
opaque content
###
AST type:
interface CommentBlock {
type: "CommentBlock"
text: string
reflection: Reflection[] | null
}
- Opening: a line whose stripped content begins
###. No[name]and no{attrs}are recognized on the opener line. Any trailing characters on the opener line are part of the opener (ignored). - Closing: the next line whose stripped content is exactly
###, as for every other fence. Indentation is not compared. To place a literal###line inside the body, escape it (§8.3). - Content is opaque — captured verbatim with no inline or block parsing. Lines joined with
\n; a single trailing\nis appended. - Legal at Page scope AND inside
ListItem,TaskItem,QuoteBlock,NamedBlock,SpoilerBlock. - Unclosed fence: content runs to end of document → warning CDN-0006. Same rule as
CodeBlock(§4.4),Meta(§4.3),MathBlock(§4.5): opaque content has no parseable structure, so the enclosing container's boundary is not observable from inside the fence. An unclosed###opened inside a container therefore absorbs every following line, including content past the container. CommentBlocktakes no part in pagination (§9.5.2): only aMetaand a PageBreaker create Page boundaries.- Default render policy is hidden: conforming renderers SHOULD omit it. See §2.5.
- No
attributesfield. - Closer escape:
\#inside the body emits a literal#(consumes the\). A line\###,#\##, or##\#therefore does NOT close the fence. The rule does not look at run length — any\#escapes. All other\Xsequences are literal. See §8.3. Opener escape: see §8.2.
Example:
Input:
intro paragraph
###
draft note — revise before publish
###
next paragraph
AST:
Paragraph([Text("intro paragraph")])
CommentBlock { text: "draft note — revise before publish\n" }
Paragraph([Text("next paragraph")])
5Inline Segments
Inline content is parsed in source order with no backtracking. When an opener has no valid matching closer before the end of the paragraph (or enclosing block), the opener is emitted as literal text.
The ## opener (§2.2) is special: it has no closer and terminates at end-of-line. When ## is encountered with one or more inline constructs open, those unclosed openers degrade to literal per the same rule. The payload is stored as a Reflection entry on the enclosing block — it does not appear in the inline stream.
Inline rules run in:
| Location | Section |
|---|---|
| Heading text | §4 |
| Paragraph content | §4 |
| List item and task item content | §4 |
| Table cell content | §4 |
alt slot of ImageBlock |
§4 |
alt slot of ImageInline |
§5 |
Caption content |
§6 |
RefDefinition content |
§4 |
Children of Emphasis, Strong, Highlight, QuoteInline, Spoiler |
§5 |
Children of Mark |
§5 |
[text] slot of Link |
§5 |
5.1Text
Syntax: Any sequence of characters not matched by another inline rule.
AST type:
interface Text {
type: "Text"
value: string
}
Consecutive text tokens MUST be merged into a single Text segment by the parser.
Text segments are literal — no inline parsing, no escape processing. A \ (backslash) at the end of a line, before the line terminator, produces a LineBreak segment (§5.13); every other character in a Text segment is literal.
5.2Emphasis
Syntax: __inline content__
AST type:
interface Emphasis {
type: "Emphasis"
children: Inline[]
attributes: Attribute[]
}
__opener and closer. A single_(underscore) is always literal text.- Run of 3:
___=__(opener/closer) +_(literal). - Matching: greedy, in source order. First valid
__closer wins. - Unclosed
__→Text("__"). - Same-type nesting not allowed. Cross-type nesting allowed (e.g.
__**text**__). - Leading/trailing whitespace inside delimiters is stripped. See §12 for full whitespace rules.
Examples:
__italic__ → Emphasis([Text("italic")])
___text___ → Emphasis([Text("_text")]) + Text("_")
__ text → Text("__") + Text(" text") (unclosed)
_ text _ → Text("_ text _") (single underscore = literal)
5.3Strong
Syntax: **inline content**
Strong emphasis. ** follows the dominant Markdown-family convention (CommonMark, GFM, Pandoc), where ** marks strong emphasis.
AST type:
interface Strong {
type: "Strong"
children: Inline[]
attributes: Attribute[]
}
**opener and closer. A single*(asterisk) is always literal text.- Same rules as
Emphasis: run of 3, greedy, unclosed = literal, no same-type nesting. - Cross-nesting with
Emphasisallowed:**__text__**and__**text**__are both valid.
5.4Highlight
Syntax: ~~inline content~~
Marks a span of text as highlighted — visually emphasized as important, like a marker pen. Highlight carries no deletion semantics; Cutdown defines no strikethrough element.
AST type:
interface Highlight {
type: "Highlight"
children: Inline[]
attributes: Attribute[]
}
~~opener and closer. A single~(tilde) is always literal text (not a meta fence in inline context).- Same rules as
Emphasis: greedy, unclosed = literal, no same-type nesting. - Cross-nesting with
EmphasisandStrongallowed.
5.5Link
Syntax: Several forms depending on link kind.
[text](url) → external link
[text][path/to/page] → page link
[text][#tag/path] → tag link
[text][^ref-id] → reference link
[text][@cite-id] → citation link
AST type:
interface Link {
type: "Link"
kind: "external" | "page" | "tag" | "ref" | "cite"
children: Inline[]
href: string // for kind: "external", otherwise empty string
target: string // for kind: "page" | "tag" | "ref" | "cite", otherwise empty string
attributes: Attribute[]
}
| Syntax | Kind | Field |
|---|---|---|
[text](url) |
"external" |
href |
[text][path/to/page] |
"page" |
target |
[text][#tag/path] |
"tag" |
target |
[text][^ref-id] |
"ref" |
target |
[text][@cite-id] |
"cite" |
target |
textis parsed by inline rules. May be empty.href/targetmay be empty strings — both are valid and preserved.- Page target uses
PATH_LITERALcharacters. Tag target:#(octothorpe) +PATH_LITERAL. Ref target:^(caret) +ID_LITERAL. Cite target:@+ any characters other than](right bracket). - Shorthand
[@cite-id](no text bracket) is NOT a citation link — emitted as plain bracket text. - Cutdown does not validate link resolution. That is the consumer's responsibility.
- Cutdown does NOT validate URL syntax and URL schema. Consumers may choose to validate or sanitize
hrefvalues.
Edge cases:
[][target] → Link { kind: "page", children: [], target: "target" }
[][] → Link { kind: "page", children: [], target: "" }
[]() → Link { kind: "external", children: [], href: "" }
All cases above are valid and preserved. Consumers may choose to warn about empty targets or hrefs.
Link types and meaning
Ordinary links [text](url) are for external URLs. At least (url) part should be treated as HTML DOM <a> href attribute value and validated/sanitized accordingly. Consumers may choose to also validate the url against a whitelist of allowed URL schemas (e.g. http:, https:, mailto:) and/or emit a warning for suspicious URLs (e.g. javascript:).
Tag links [text][#tag/path] are for linking to a tag or category within the whole project where document is defined. The target field contains the tag name, which consumers can resolve according to their internal tagging logic. Cutdown does not specify where tags are defined or how they are resolved.
Reference links [text][^ref-id] are for linking to a reference definition elsewhere in the same document. There can be two way ref-id resolution. Either anywhere in the document the [^ref-id]: target syntax is used to define a RefDefinition segment with the given id, or id attribute of any segment matches the ref-id.
Sample text with a reference to [the API][^api-doc] and [the API table][^api-table].
| Endpoint | Description |
| ... | ... | {#api-table}
[^api-doc]: https://example.com/api-doc
Citation links [text][@cite-id] are for linking to a bibliography entry or similar external reference. The target field contains the citation ID, which consumers can resolve according to their internal citation logic.
Page links [text][path/to/page] are for linking to other pages within the same site or system. The target field contains the page path, which consumers can resolve according to their internal routing logic. Nevertheless, the client may choose to validate the target against a whitelist of allowed page paths and/or emit a warning for suspicious targets.
5.6CodeInline
Syntax: '``code``'
AST type:
interface CodeInline {
type: "CodeInline"
value: string
attributes: Attribute[]
}
- Double backtick only. Single backtick is always literal text.
- Content is literal — no inline parsing. One escape sequence is processed:
\`→ literal`(does not close the span). Every other backslash is literal, including\\(two literal backslashes) and any other\X(per §8 non-special rule). See §8.3. - Unmatched '``' →
Text("``"). Single '`' →Text("")`. - Triple backtick in inline context: ``` = `` (opener) + ` (literal inside).
- Whitespace collapsing does NOT apply to
CodeInline. A soft break inside ``...`` is folded to zero. See §12 for full whitespace rules.
Examples:
``code`` → CodeInline { value: "code" }
`not code` → Text("`") + Text("not code") + Text("`")
```text``` → CodeInline { value: "`text" } + Text("`")
``test
continues`` → CodeInline { value: "testcontinues" } (soft break → zero)
``\`\`\`x`` → CodeInline { value: "```x" } (escape lets multi-backtick embed)
``a\\b`` → CodeInline { value: "a\\b" } (\\ stays as two literal backslashes)
``\n`` → CodeInline { value: "\n" } (unknown \X stays literal: backslash + n)
5.7MathInline
Syntax: $$formula$$
AST type:
interface MathInline {
type: "MathInline"
formula: string
attributes: Attribute[]
}
$$opener and closer. A single$is always literal text.- Content is literal — no inline parsing. Passed as raw string to the consumer (KaTeX or equivalent).
- Run of 3:
$$$at inline position =$$(opener/closer) +$(literal). - Unclosed
$$→Text("$$"). Same-type nesting not allowed.
Examples:
$$ a^2 + b^2 $$ → MathInline { formula: " a^2 + b^2 " }
$$unclosed → Text("$$") + Text("unclosed")
$ not math $ → Text("$ not math $")
5.8QuoteInline
Syntax: "" content "" or '' content ''
AST type:
interface QuoteInline {
type: "QuoteInline"
kind: "double" | "single"
children: Inline[]
attributes: Attribute[]
}
""= double-quote style (kind: "double").''= single-quote style (kind: "single").- A single
"or'is always literal text. - Same-kind nesting not allowed. Cross-kind nesting IS allowed:
""'' inner ''"". - Unmatched opener →
Text('""')orText("''"). - Whitespace rules follow §12 (boundary whitespace stripped; adjacent boundaries collapsed).
Examples:
"" hello "" → QuoteInline { kind: "double", children: [Text("hello")] }
'' hi '' → QuoteInline { kind: "single", children: [Text("hi")] }
""'' inner ''"" → QuoteInline { kind: "double", children: [QuoteInline { kind: "single", ... }] }
"" unclosed → Text('""') + Text(" unclosed")
5.9ImageInline
Syntax:  {attrs}
AST type:
interface ImageInline {
type: "ImageInline"
alt: Inline[]
src: string
attributes: Attribute[]
}
altis parsed by inline rules. Result isInline[]. Despite thealtcontext allows full inline syntax, it should be treated as technical possibility rather than a common use case. Consumers may choose to restrict or ignore certain inline features inalt(e.g. links, images, formatting) or strip it down to plain text.altmay be empty.srcis a raw string. No URL validation or sanitization by Cutdown; consumers may choose to validate/sanitize as needed.srcmay be empty.![]()is valid and preserved asImageInline { alt: [], src: "" }.- Captions:
ImageInlinedoes not participate in the caption line (§6.2) — it is not a block. OnlyImageBlock(§4.9) is captionable. - ImageInline vs ImageBlock (§4.9): a line at block level beginning with
![is classified as anImageBlock, and anImageBlockmust be the only segment on its line. An image anywhere else — mid-line, or on a line carrying other content — is anImageInline. See §4.9.
5.10Mark
Syntax: ::name <content>:: or ::name::
A named inline container that wraps a range of content as a hook for consumer post-processing. Supports nesting.
AST type:
interface Mark {
type: "Mark"
name: string // non-empty ID_LITERAL run
children: Inline[] // empty for the `::name::` form
attributes: Attribute[]
}
Opener. :: followed immediately by a name — a maximal ID_LITERAL+ run (§1.2). The character after the name decides what happens:
| Next character | Result |
|---|---|
:: |
empty Mark; the construct ends there |
| space | container opener; content runs to the matching closer |
| anything else | not a Mark — emit Text("::" + name) and continue parsing |
The name is required. :: not followed by at least one ID_LITERAL character is emitted as literal Text("::"). The space after the name is a delimiter and is not part of the content.
Closer. A bare ::. While scanning content, a :: is a nested opener if it is followed by ID_LITERAL+ and then a space or ::; otherwise it is the closer. A :: encountered with no Mark open is literal text.
- Because the opener (
::name) is textually distinct from the closer (::),Markis bracket-matched by counting, not by greedy first-closer. This makes it the only inline construct in the language that permits same-type nesting — including nesting aMarkof the same name (§9.4.1, Class 3). - Cross-nesting with
Emphasis,Strong,Highlight,Spoiler,QuoteInlineandLinkis allowed in both directions. - Children are parsed by inline rules (see the context table at the head of §5).
- Maximum nesting depth is 8. A
Markopener at depth 9 or deeper does not open; it degrades to literalText("::" + name)and emits warning CDN-0031. - An unclosed opener degrades per Class 1 (§9.4.1): the opener alone is emitted as
Textand parsing continues immediately after it. No diagnostic. - Attributes trail the closer, glued to it as for every other inline node:
::a b::{#x .y}. - Whitespace inside the delimiters follows §12, as for any container inline.
Name lexing. The name is a maximal ID_LITERAL+ run and is lexed at the opener, before delimiter matching. _, - and . are ID_LITERAL characters, so they are absorbed into the name; *, ^, $, ` and space are not, and terminate the name scan. This is a lexical property and is independent of the precedence table (§11):
::a__b__:: → Mark { name: "a__b__" } `_` is ID_LITERAL — the name swallows it, no Emphasis forms
::a**b**:: → Text("::a") + Strong([Text("b")]) + Text("::") `*` is not — the name ends at `a`, the opener fails
Colon runs. A run of three colons in inline position is the :: closer plus a literal :. At block position :::name is still a NamedBlock (§4.13) — that classification happens first and never reaches the inline parser.
Examples:
::a:: → Mark { name: "a", children: [] }
::a b:: → Mark { name: "a", children: [Text("b")] }
::a ::c:::: → Mark { name: "a", children: [Mark { name: "c", children: [] }] }
::a b ::c d:: e:: → Mark { name: "a", children: [Text("b "), Mark { name: "c", children: [Text("d")] }, Text(" e")] }
::a ::a ::a :::::: → Mark(a, [Mark(a, [Mark(a, [])])]) same-name nesting is legal
::a::b → Mark { name: "a" } + Text("b")
::a::b :: → Mark { name: "a" } + Text("b ::") closer with nothing open is literal
::a::b :::: → Mark { name: "a" } + Text("b ::::")
:: :: → Text(":: ::") no name
::a b __c__ → Text("::a b ") + Emphasis([Text("c")]) unclosed opener, Class 1
::a b::{#x .y} → Mark { name: "a", children: [Text("b")], attributes: {id:"x", class:["y"]} }
5.11Variable
Syntax: {{key}}
AST type:
interface Variable {
type: "Variable"
key: string
attributes: Attribute[]
}
keyMUST useID_LITERALcharacters:[a-zA-Z0-9._-]. A{{...}}with invalid key characters is emitted as literal text → warning CDN-0015.- Unclosed
{{→ literal text. Empty-key{{}}→ literal text → warning CDN-0015. - Variables are only parsed in inline contexts where inline rules are active (not inside code/math/meta blocks).
- Brace tie-break:
{{is always matched before{(longest opener wins). - May carry trailing attributes:
{{key}} {#id .class}.
5.12Spoiler
Syntax: ^^inline content^^
AST type:
interface Spoiler {
type: "Spoiler"
children: Inline[]
attributes: Attribute[]
}
^^opener and closer. A single^is always literal text in inline context. (Inside[...][^id]link/definition target slots,^retains its reference-marker role per §5.5 / §4.14 — that context is delimited and never reaches the Spoiler parser.)- Cutdown emits the AST node
Spoiler; consumers choose the rendering (click-to-reveal, blur, redaction, NSFW mask). Semantic variants are conveyed via attributes (e.g.{.nsfw},{.redacted}). - Same rules as
Emphasis: run of 3 (^^^in inline context =^^opener/closer +^literal), greedy close in source order, unclosed^^→Text("^^"), no same-type nesting. - Cross-nesting with
Emphasis,Strong,Highlight,QuoteInline, andLinkis allowed. - Leading/trailing whitespace inside delimiters is stripped. See §12 for full whitespace rules.
Examples:
^^hidden^^ → Spoiler([Text("hidden")])
^^^x^^^ → Spoiler([Text("^x")]) + Text("^")
^^ open → Text("^^") + Text(" open") (unclosed)
^ not spoiler ^ → Text("^ not spoiler ^") (single caret = literal)
__^^x^^__ → Emphasis([Spoiler([Text("x")])]) (cross-nesting)
^^a ^^b^^ c^^ → Spoiler([Text("a")]) + Text("b") + Spoiler([Text("c")]) (greedy)
5.13LineBreak
Syntax: \<EOL>, backslash as the last character of a line (before \n). The construct is the LineBreaker; it produces the LineBreak node, as PageBreaker (§4.10) produces a page boundary.
AST type:
interface LineBreak {
type: "LineBreak"
}
A LineBreak asserts an author-intended line break within a paragraph — the surrounding text stays one block. It is not a paragraph boundary: a blank line ends the block and starts a new Paragraph, whereas a LineBreak breaks the line and keeps the block.
Cutdown emits the AST node LineBreak; consumers choose the rendering (a <br>, a newline in plain text, a no-op in a single-line context). See §16 — Cutdown has no canonical rendering.
- The
\and the following newline are consumed. Inline parsing continues on the next line.- The
\must be the last non-whitespace character on the line. Trailing whitespace, and a trailing##comment (which consumes the rest of the line as a reflection entry, §2.2), are ignored when making this test.
- The
Line-ending summary:
| Line ending | Result |
|---|---|
word\n |
Soft break — folded to zero; lines concatenate directly |
word \n |
Trailing space collapsed to single space — Text("word ") emitted |
word\\n |
LineBreak segment — explicit line break inside the paragraph |
word\n\n |
Blank line — ends the block; the next line starts a new Paragraph |
Line Comment (##) — not an inline segment
Syntax: content ## trailing comment
Line Comment runs till the end of line (EOL). Payload captured as a Reflection on the enclosing block, omitted by renderers by default (§2.5).
See §2.2 for the full normative semantics. Summary:
- Recognized at line-start or mid-line; runs to EOL; opaque to all other delimiters.
- Payload stored as
Reflectionentry on the enclosing block — does not appear in the inline stream. - A single
#is always literal (see §10.4.4).###at inline position →##(comment opener) + trailing#in payload. - Not recognized inside
CodeInline,MathInline, or quoted attribute values. - Escaped with
\##or#\#.
6Segment Attribution (Universal Attributes and Caption)
6.1Universal Attributes
AST type: Attribute[] — see §14 Attributes Type for the definition.
6.1.1Syntax
{#id .class key=value key="value with spaces"}
Token types inside {}:
#identifier— the#(octothorpe) sets theidattribute. Emits{ key: "id", value: "identifier" }. First#wins; any subsequent#idorid=token is dropped and CDN-0020 is emitted..classname— appends to theclassentry. All.classnametokens in a block are collected into a single{ key: "class", value: string[] }entry..classsyntax has priority: ifclass=also appears,class=is dropped and CDN-0021 is emitted.key=value— custom attribute. Emits{ key: "key", value: "value" }. Unquoted value: no spaces. Quoted value: spaces allowed. First occurrence wins; duplicate keys are dropped and CDN-0022 is emitted.key(bare, with no=(equals sign)) — flag token. Emits{ key: "key", value: "" }. Signals a semantic hint with no associated value.- Token order inside
{}is preserved in the emittedAttribute[].
Philosophy: Universal Attributes are semantic hints for consumers — they are not one-to-one mappings to HTML attributes. A bare
{banner}does not mean<div banner>; it means "this element has the semantic role 'banner'." The consuming application decides how to expand, map, or ignore any attribute token. Cutdown makes no assumption about the rendering target.
6.1.2Placement
Attributes MUST appear after their target element on the same line.
== Section {#intro}
**bold text** {.highlight}
:::callout {.warning}
Whitespace between a segment and its attaching {...} is consumed by the attachment and does not appear in the AST. See §12 for the full rule.
Block Opening Lines — Last-Attr Rule
On lines that open a block construct (headings, named blocks), the last {...} token on the line is claimed by the block. All earlier {...} tokens on the same line follow inline attachment rules and bind to their immediately preceding inline element.
An empty {} as the last token explicitly assigns no attributes to the block, freeing earlier {...} to bind inward.
= Heading **bold**{.b} {#h} → Section({id:"h"}, [Text("Heading "), Strong({class:"b"}, "bold")])
= Heading **bold**{.b} {} → Section({}, [Text("Heading "), Strong({class:"b"}, "bold")])
= Heading **bold** {#h} → Section({id:"h"}, [Text("Heading "), Strong("bold")])
This rule applies only in block opening line context. In paragraph context all {...} follow inline attachment rules exclusively.
Per-segment placement rules
Meta: no attributes supported.RefDefinition: hasattributes.Table: see §4 for row and table attr placement.List / FileRefGroup / ImageGroup / QuoteBlock / Paragraph: scope-chain rule (Rule B) — see below.Cell,Column,Page,Document: no attributes supported.
Scope-chain rule (Rule B)
A sequence of {attr} blocks at the end of an inline context is distributed right-to-left through a scope chain. The last {} in the sequence is claimed by the highest segment in the current hierarchy; each preceding {} claims the next lower segment. Any {} blocks at the front of the sequence that have no segment to claim are silently dropped.
An empty {} is valid syntax. It claims its slot and assigns nothing to that segment's attributes.
{...} is tokenized atomically — the interior is never parsed as inline markup. If the { (left brace) has no matching } (right brace) before end of inline context, the entire slice from { to end of line is emitted as one verbatim Text run (Class 2 degradation, §9.4.1; see §6.1.3).
Scope slots by context:
| Context | Slot 1 (last {}) |
Slot 2 | Slot 3 |
|---|---|---|---|
| Standalone Paragraph | Paragraph | last attr-bearing inline | — |
| List item | List | ListItem | last attr-bearing inline |
| Table row — mid-table, cell open | Row | last attr-bearing inline | — |
| Table row — mid-table, cell sealed | Row | — | — |
| Table row — last row, cell open | Table | Row | last attr-bearing inline |
| Table row — last row, cell sealed | Table | Row | — |
| FileRef / ImageBlock in group | FileRefGroup | FileRef / ImageBlock | last attr-bearing inline |
QuoteBlock nesting (> >) |
outermost QuoteBlock | … inner levels … | Paragraph → inline |
Table rows. Cell bears no attributes (see Per-segment placement rules above), so the chain walks past the cell to the last attr-bearing inline inside it. Whether that slot exists depends on the trailing | (pipe):
- Cell open — the row omits the trailing
|, so the chain sits inside the last cell's inline context and the inline slot is available. - Cell sealed — the row writes the trailing
|, closing the last cell's inline context before the chain begins. There is no open inline context, so the inline slot does not exist and the chain stops atRow.
A chain written before the closer belongs to the cell, not the row. Every cell is its own inline context, so Rule B applies inside it with the cell's own slots — and since Cell bears no attributes, the only slot is that cell's last attr-bearing inline. This holds for any cell, not just the last.
| AA | **BB** {.a} → Table({.a}, Row(...)) ← open: row chain, slot 1 = Table
| AA | **BB** | {.a} → Table({.a}, Row(...)) ← after the closer: row chain
| AA | **BB** {.a} | → Row(Cell(...), Cell(Strong({.a}, "BB"))) ← before the closer: cell chain
| **AA** {.x} | BB | → Row(Cell(Strong({.x}, "AA")), Cell("BB")) ← non-final cell
So the closer sets how deep the row chain reaches, and the chain's position relative to the closer selects which chain applies. A cell chain has exactly one slot; a second group orphans (CDN-0011).
The slot searches the last cell only. If that cell holds no attr-bearing inline, the slot goes unclaimed and the {} is dropped with CDN-0011 (§6.1.3) — the chain never scans sideways into an earlier cell, exactly as - text {.a}{.b}{.c} drops .a rather than hunting leftward.
| AA | **BB** {.a}{.b}{.c} → Table({.c}, Row({.b}, Cell(...), Cell(Strong({.a}, "BB"))))
| AA | **BB** | {.a}{.b}{.c} → Table({.c}, Row({.b}, ...)) ← sealed; {.a} dropped
| AA | **BB** | CC {.a}{.b}{.c} → Table({.c}, Row({.b}, ...)) ← last cell is Text; {.a} dropped
Single NL does not break the attr chain. A sequence of {} blocks may span multiple lines (one per line) as long as no blank line appears between them.
- ::sp:: {.a}{.b}{.c} → List({.c}, ListItem({.b}, Mark({.a})))
- ::sp:: {.a}{.b} → List({.b}, ListItem({.a}, Mark()))
- ::sp:: {.a} → List({.a}, ListItem(Mark()))
- ::sp:: {} → List({}, ListItem(Mark())) ← {} no-op on List
- ::sp:: {.a}{} → List({}, ListItem({.a}, Mark())) ← {} no-op on List; {.a} to ListItem
- text {.a}{.b} → List({.b}, ListItem({.a}, Text("text")))
- text {.a} → List({.a}, ListItem(Text("text")))
- text {.a}{.b}{.c} → List({.c}, ListItem({.b}, Text("text"))) ← {.a} dropped (Text has no attrs)
Same rule applies to FileRefGroup and ImageGroup:
/img.png {.a}{.b} → FileRefGroup({.b}, FileRef({.a}, ...))
/img.png {.a} → FileRefGroup({.a}, FileRef(...))
/img.png {} → FileRefGroup({}, FileRef(...)) ← {} no-op on group
/img.png {.a}{} → FileRefGroup({}, FileRef({.a}, ...))
Multiline equivalents (all produce identical AST):
- li item {.a}{.b}
- li item
{.a}{.b}
- li item
{.a}
{.b}
Trailing attr lines and loose-list detection
A line consisting solely of {attrs} immediately after a list item with no preceding blank line is a trailing attr line, not a blank line. Loose list detection ignores trailing attr lines.
- item one
{.attrs} ← trailing attr line, NOT a blank line → list stays tight
- item two
- item one
{.attrs} ← blank line precedes → list is loose; {.attrs} is orphaned (literal)
- item two
{attrs} may appear at the end of a Paragraph or ListItem either on the same line as the final content line, or on the immediately following line (no blank line between). A line consisting solely of {attrs} immediately after a paragraph (no blank line) is consumed as part of the paragraph's scope chain and does not start a new block.
6.1.3Orphan Attributes
An {...} sequence that cannot be assigned to any segment in the current scope chain is an orphan.
Orphan behaviour depends on position:
| Position | Behaviour | Example |
|---|---|---|
| Middle of inline content (no preceding attr-bearing segment, slots exhausted) | Text("{...}") emitted verbatim |
price is {high} → Text("price is ") + Text("{high}") |
| After a double blank line (own block, no following content claims it) | Text("{...}") emitted verbatim |
|
End of scope chain, all slots filled, excess {} at the front |
dropped (no AST output), CDN-0011 emitted | {.x}{.a}{.b} on a 2-slot context → {.x} dropped |
{ is always consumed as the start of an attribute scan, running to the matching } or end of line. If the content violates the attribute grammar or the } never arrives, the entire slice — braces included, when present — is emitted as one verbatim Text run and is never inline-parsed (Class 2 degradation, §9.4.1). This is the intentional literal-span idiom: {a **b**} is the literal text {a **b**}. Consequence: any future extension of the attribute grammar is a breaking change for text relying on this idiom.
Authors who want a literal { SHOULD escape it with \{ to make intent explicit:
price is \{high\} → Text("price is {high}")
6.1.4Attribute Inside Link Text
{attrs} appearing inside [...] follows paragraph rules: it attaches to the preceding inline element, or is dropped if no preceding inline element exists.
[**bold** {.foo}](url) → Link { children: [Strong({class:"foo"}, "bold")], href: "url" }
[{.foo} text](url) → Link { children: [Text("text")], href: "url" } ({.foo} dropped)
6.2Caption
A caption line enriches the immediately preceding captionable block with a caption field. It does not produce a separate AST node.
Syntax:
^ inline-content
A line at block start consisting of ^ (caret) followed by a single space and then any inline content. The ^ and space are consumed; the remainder is parsed as Inline[].
Binding rule: A caption line binds to the immediately preceding block in the current block scope when all three hold:
- That block is captionable (see table below);
- That block's caption slot is empty;
- No blank line separates the block's last line from the
^line.
Otherwise the caption line is orphaned: it becomes a Paragraph containing the line verbatim, and warning CDN-0008 is emitted.
Lines that emit no block are transparent — they are not "the immediately preceding block" and do not break binding. Any number of them may intervene:
- a trailing
{attrs}line (sets the block's attributes; emits no node); - a standalone
## commentline (attaches to the preceding block'sreflection, §2.2).
| col |
{#tbl-one}
^ Caption text → Table { caption: [...], attributes: [{id:"tbl-one"}] }
| col |
## editorial note
^ Caption text → Table { caption: [...], reflection: [{ loc: { start: 11, end: 25 }, text: "editorial note" }] }
Single-line only. A caption is exactly one line. A second consecutive ^ line fails condition 2 — the slot it would claim is already filled — so it orphans.
The "preceding block" is always resolved within the current block scope (§1.6). A ^ line inside a NamedBlock binds to the last captionable child of that NamedBlock, not to anything outside it.
Captionable blocks and their AST fields:
| Block | Field added |
|---|---|
Table |
caption: Inline[] | null |
ImageBlock |
caption: Inline[] | null |
CodeBlock |
caption: Inline[] | null |
MathBlock |
caption: Inline[] | null |
FileRef |
caption: Inline[] | null |
FileRefGroup |
caption: Inline[] | null |
NamedBlock |
caption: Inline[] | null |
SpoilerBlock |
caption: Inline[] | null |
QuoteBlock |
caption: Inline[] | null |
All captionable blocks default these fields to null when no caption line is present.
FileRefGroup mid-run close. A ^ line immediately after a FileRef or ImageBlock that is part of an active group closes the group at that point and binds to it. The next FileRef/ImageBlock starts a new group:
/a.png
/b.png
^ Two-image group caption
/c.png
^ Single-image group caption
Inline content. The caption text is parsed by the full inline rule set:
##behaves normally — consumes to EOL; payload stored in the caption's parent blockreflectionat the caption line's offset.{{variable}}is allowed —Variablenodes are valid caption content.- A trailing
{attrs}sequence at end of the caption line is emitted as literal text and does not attach to anything → warning CDN-0009. Caption text inherits no scope-chain slot; the parent block's attributes are set independently.
Escaping. \^ at line start suppresses the caption opener → Paragraph([Text("^ ...")]). See §8.2.
Example:
Input:
| Name | Score |
|:-----|------:|
| Alice | 42 |
^ Results from the first cohort
AST:
Table {
caption: [Text("Results from the first cohort")],
rows: [Row(type:"Header",...), Row(type:"Row",...)],
attributes: []
}
Input:
> To be, or not to be.
^ William Shakespeare, __Hamlet__, Act 3
AST:
QuoteBlock {
caption: [Text("William Shakespeare, "), Emphasis([Text("Hamlet")]), Text(", Act 3")],
children: [Paragraph([Text("To be, or not to be.")])],
attributes: []
}
7Input Interpretation
Cutdown does not preprocess the input into a normalized copy. The parser reads the raw file and applies the following interpretive rules — the source text is never rewritten, so there is exactly one coordinate system: UTF-16 code-unit offsets into the raw input (see §14, Location Type).
- Encoding: Cutdown receives decoded text from a valid UTF-8 input boundary. Decoding transport bytes, including buffering an incomplete multi-byte sequence, happens before Cutdown parsing. A persisted Cutdown file MUST be valid UTF-8; malformed byte sequences are outside the Cutdown input model.
- Unicode normalization: Identifiers (labels, references, mentions) are compared under NFC — two identifiers match if their NFC forms are equal. The source text itself is never rewritten to NFC (a textual normalization pass would shift
locoffsets). Authors SHOULD store files in NFC. - BOM: A UTF-8 BOM (
U+FEFF) at the start of the input is skipped — the first content offset is 1 instead of 0. A BOM appearing anywhere else in the document is treated as a regular Unicode character. - Null bytes:
U+0000is not valid content. The parser MUST emitU+FFFD(Unicode replacement character) in its place inTextvalues and MAY emit a diagnostic.U+0000andU+FFFDare both one UTF-16 code unit, so offsets are unaffected. - Line terminators:
\r\n, lone\r, and\nare all line terminators. This is lexer behavior, not a rewrite: in a\r\nfile the\ris simply part of the terminator, and line content ends before it. If the input does not end with a line terminator, the lexer treats end-of-input as a virtual terminator; no text is appended. - Document-edge blank lines: Leading and trailing blank lines (lines containing only whitespace characters per §10.1) are skipped by the block phase — they produce no blocks and no diagnostic. A document consisting entirely of blank lines produces an empty AST. The skipped text remains part of the raw input for offset purposes.
- Tabs: A tab character (
\t) outside fenced blocks is treated as a single space (U+0020) for all structural and inline purposes. Tab and space are both one UTF-16 code unit, so offsets are unaffected. No diagnostic is emitted.- Leading tab on a block line → treated as one leading space, then disregarded by block classification
- Tab inside inline content → treated as a single space (participates in whitespace collapsing)
- Tab inside code/meta/math fences → preserved literally in the emitted
rawvalue
- HTML entities: HTML character references (
&,<, , , etc.) are not decoded. They are emitted as literalTextsegments. The parser has no HTML entity table. Consumers rendering to HTML are responsible for deciding whether to re-encode or pass through. - BiDi Control Characters: Unicode bidirectional control characters (e.g.,
U+200ELRM,U+200FRLM,U+2066–U+2069Isolates) MUST be preserved literally inTextsegments. The parser performs no special BiDi-aware reordering; it operates strictly on logical character order.
7.1Streaming input snapshots
A producer MAY make source available incrementally. After each decoded Unicode scalar value, the available source is an input snapshot and MUST be parsed by the ordinary Cutdown grammar. End of input is a virtual line terminator as defined above, whether it denotes a saved file, a cancelled producer, or the source currently available from a still-open producer.
Cutdown defines neither chunks nor a completion signal. Ordering, retries, replacements, deletion, synchronization, and producer identity are outside this specification.
8Escaping
Rule: A \ (backslash) before a special character emits the literal character. The \ is consumed.
A backslash before a non-special character emits both the backslash and the character literally. The \ is NOT consumed silently.
\* → Text("*")
\\ → Text("\")
\a → Text("\a")
8.1Special Characters
The following characters are special and may be escaped:
= # * _ ~ ` $ [ ] ( ) ! { } : - > / \ | " ' ^
Escaping inside opaque containers (CodeInline, CodeBlock, Meta, MathBlock, CommentBlock) follows narrow per-construct rules — see §8.3.
8.2Block-Opener Escapes
A backslash before any one character of a line-start block marker suppresses the marker. The line becomes a Paragraph with the marker chars emitted as literal text. The \ is consumed; marker chars are kept verbatim.
Applies at line start only (after container-indent stripping, before the first non-whitespace char of the marker). Mid-line occurrences of these chars are governed by §8 inline rules.
| Construct | Marker | Escape forms (all equivalent) | Result |
|---|---|---|---|
| Heading (§4.2) | = ... ========= |
\=, \==, \=== ... |
Paragraph([Text("= ...")]) |
| List item (§4.7) | - |
\- item |
Paragraph([Text("- item")]) |
| QuoteBlock (§4.6) | > |
\> quoted |
Paragraph([Text("> quoted")]) |
| PageBreaker (§4.10) | --- |
\---, -\--, --\- |
Paragraph([Text("---")]) — top level only; no Page boundary occurs |
| FileRef (§4.11) | /path |
\/path |
Paragraph([Text("/path")]) |
| CodeBlock (§4.4) | ``` |
\```, `\ , \` |
Paragraph; residual backticks still feed inline parsing |
| Meta (§4.3) | ~~~ |
\~~~, ~\~~, ~~\~ |
Paragraph([Text("~~~")]) |
| MathBlock (§4.5) | $$$ |
\$$$, $\$$, $$\$ |
Paragraph([Text("$$$")]) |
| CommentBlock (§4.16) | ### (line start) |
\###, #\##, ##\# |
Paragraph([Text("###")]) |
| NamedBlock (§4.13) | :::name |
\:::name, :\::name, ::\:name |
Paragraph([Text(":::name")]) — no CDN-0013 |
| SpoilerBlock (§4.15) | ^^^ |
\^^^, ^\^^, ^^\^ |
Paragraph([Text("^^^")]) |
| Caption (§6.2) | ^ |
\^ text |
Paragraph([Text("^ text")]) |
| Table row (§4.8) | | |
| cell | |
Paragraph([Text("| cell |")]) |
Notes:
- The escape suppresses the block opener only. Residual characters re-enter normal processing, regardless of which of the three marker chars carried the escape. Example:
\```,`\, and\`all become a Paragraph containing three backtick characters; inline parsing then opens aCodeInlinefrom the first two and leaves the third as a literal`. To produce three literal backticks, escape each:\`\`\`. - Attributes that follow an escaped marker are literal text — there is no block to attach them to.
\--- {.cover}→Paragraph([Text("--- {.cover}")]). - The rule applies in every block scope (Page scope, ListItem, TaskItem, QuoteBlock, NamedBlock, SpoilerBlock).
##(line comment, mid-line) is escaped with\##or#\#per §2.2 — that is a separate inline escape, not a block-opener escape.- No diagnostic is emitted when an opener is escaped. The backslash is a deliberate author signal.
8.3Opaque-Block Closer Escapes
Opaque containers (whose content is captured verbatim) admit a single narrow escape: the fence character itself. All other backslashes inside opaque content are literal.
| Container | Escape | Result | Other \X inside |
|---|---|---|---|
| CodeInline (§5.6) | \` |
literal ` |
literal \X (including \\ → two chars) |
| CodeBlock (§4.4) | \` |
literal ` |
literal \X |
| Meta (§4.3) | \~ |
literal ~ |
literal \X |
| CommentBlock (§4.16) | \# |
literal # |
literal \X |
| MathBlock (§4.5) | — none | n/a | fully literal (LaTeX owns \) |
Notes:
- The escape applies uniformly throughout opaque content — line-start or mid-line.
\fenceanywhere producesfence. A closer line\~~~,~\~~, or~~\~keeps the Meta block open and emits a literal~~~content line. (Mid-line uniformity is a simplification — closers are only detected at line start, so mid-line escape has no closer-suppression effect; it just affects the captured raw character.) \\inside an opaque (non-Math) block emits two literal characters\\. Backslash is "active" only directly before its fence character.\#in a CommentBlock always escapes to#(octothorpe), regardless of how many#characters neighbor it. The rule does not look at run length.- MathBlock carve-out: no escape is processed because LaTeX assigns its own meaning to backslash. The trade-off is that a literal
$$$line inside a MathBlock body is unsupported — wrap such content in aCodeBlockor split the math. - NamedBlock (
:::) and SpoilerBlock (^^^) are not opaque — their children are parsed as blocks. To prevent a content line from being read as the container's closer, use the same escape mechanic as §8.2: a\before any one of the three fence chars at line start (e.g.,\:::,:\::,::\:for NamedBlock;\^^^,^\^^,^^\^for SpoilerBlock). The line becomes a Paragraph containing the literal fence chars. - No diagnostic is emitted for closer escapes.
9Parsing Algorithm
Cutdown's parsing model makes three testable guarantees:
- Single pass. Every character of an input snapshot is scanned a bounded number of times; parsing is linear time in snapshot length. Degradation emits verbatim substrings identified by source offset — committed text is never re-lexed or re-inline-parsed. An incremental implementation MAY retain an unresolved suffix between snapshots; its result for each snapshot MUST equal parsing that snapshot afresh (§16).
- Bounded lookahead. At most one line at block level; at most to end of line at inline level.
- Deferred attachment. A deferred structural decision attaches or regroups already-built nodes only; it never re-parses text. The deferral windows are: one-block emission latency (a caption line or attribute-continuation line may bind to the preceding block) and open-inline buffering until end of line.
9.1Phase 1 — Input Interpretation
- Receive decoded text from the UTF-8 input boundary (§7).
- Apply the interpretive rules of §7:
\r\n/\r/\nall read as line terminators, tabs outside fences read as single spaces, leading BOM skipped. The source text is never rewritten — all offsets index the raw input (§14, Location Type).
9.2Phase 2 — Block Identification
- Split input into lines.
- Detect
##boundaries and record Reflection payloads. Walk lines top-to-bottom, maintaining "opaque context" state (insideCodeBlock,Meta,MathBlock, orCommentBlock). Within an opaque context, the opener line and the closer line are scanned; the body lines between them are not. On all other lines, scan in source order for the first un-escaped##not occurring insideCodeInline(``),MathInline($$), or a quoted attribute value. If found: characters before##are the line's structural content; characters from##to (but not including)\nare the comment payload. Block classification (Phase 3) operates on the pre-##substring. The payload is later attached to the appropriate block as aReflectionentry (§2.2) — it does not enter the inline stream. - Identify block boundaries: a sequence of non-blank (in pre-
##content) lines bounded by blank lines (or document start/end) is a block candidate. - Apply the fenced constructs that override blank-line boundaries —
```,~~~,:::,$$$,^^^and###. Each opens until its closer or end of document; see §10.4.2 for the shared fence pattern and §4.3–§4.16 for each construct.
9.3Phase 3 — Block Classification
Each block candidate is classified by its first line:
| First line matches | Block type |
|---|---|
^(={1,9}) |
Heading → Section |
^--- |
PageBreaker (top level only; no node — §9.6) |
^ ``` ` |
CodeBlock |
^~~~ |
Meta |
^:::[ID_LITERAL] |
NamedBlock |
^| |
Table |
^> |
QuoteBlock |
^- or ^- \[[ x]\] |
List (unordered / task) |
^[0-9]+\. |
List (ordered) |
^\[^[ID_LITERAL] |
RefDefinition |
^\$\$\$ |
MathBlock |
^\^\^\^ |
SpoilerBlock |
^### |
CommentBlock |
^/ |
FileRef |
^!\[ |
ImageBlock — provisional; falls back to Paragraph if the image is not the only segment on the line (§4.9) |
| (anything else) | Paragraph |
9.4Phase 4 — Inline Parsing
Inline content is parsed in source order within each block that contains inline content. The parser:
- Scans for openers (
**,__,~~,^^, '``',[,![,::,{{,"",'',$$). - On finding an opener, scans forward for a valid closer.
- If no valid closer is found, the opener degrades. The degradation rule depends on the opener's class (§9.4.1).
- Resolves escape sequences
\xbefore delimiter matching. - Collects trailing
{attrs}after each completed inline element.
9.4.1Degradation classes
Inline openers fall into two classes with different degradation behavior. In both cases no diagnostic is emitted — degradation to visible literal text is silent by design.
Class 1 — symmetrical doubled delimiters: opener-as-text.
| Opener | Construct |
|---|---|
** |
Strong |
__ |
Emphasis |
~~ |
Highlight |
^^ |
Spoiler |
`` |
CodeInline |
$$ |
MathInline |
"" / '' |
QuoteInline |
If no closer is found before the end of the inline context, the opener alone is emitted as Text and parsing continues immediately after it. Constructs following the dead opener are parsed normally: **a __b__ c yields Text("**a "), Emphasis(b), Text(" c").
Class 2 — asymmetrical bracket-like openers: verbatim slice.
| Opener | Construct |
|---|---|
[ / ![ |
Link / ImageInline |
{{ |
Variable |
{ |
attribute scan (§6) |
An unresolved Class 2 opener causes the source from the opener to its terminator — end of line, or the ## cut (§2.2) — to be emitted as a single verbatim Text run, copied from the source by offset. The slice is never inline-parsed: closed constructs inside a dead slice are lost (they remain literal). Constructs committed before the opener are retained. [a __b__ c yields Text("[a __b__ c") — the Emphasis inside the dead slice does not exist.
Attribute braces. { (left brace) opens an attribute scan running to the matching } (right brace) or end of line. If the content violates the attribute grammar (§6) or the } never arrives, the entire slice — braces included, when present — is emitted as verbatim Text and never inline-parsed. This is the intentional literal-span idiom: {a **b**} is the literal text {a **b**}. Consequence: any future extension of the attribute grammar is a breaking change for text relying on this idiom.
Class 3 — bracket-matched, counted: Mark.
::name … :: (§5.10) is the only construct whose opener and closer are textually distinct, so the parser matches them by counting rather than by taking the first closer. The matching rule, the depth cap and CDN-0031 are defined in §5.10.
An opener that never matches degrades per Class 1: the opener alone (:: plus the name) is emitted as Text and parsing continues immediately after it, so following constructs parse normally.
## boundaries are NOT re-scanned during Phase 4 — they were established in Phase 2 (§9.2). The inline parser receives only the pre-## substring of each line. When that substring leaves an inline opener unclosed (e.g. [text with no ] (right bracket) because ## swallowed it), the opener degrades per its class (§9.4.1) — for a Class 2 opener the ## cut acts as the slice terminator. See §2.2 for examples.
Reference links ([text][^ref]) are emitted as Link { kind: "ref" } in-place. Resolution against RefDefinition segments is the consumer's responsibility.
Citation links ([text][@cite], including [][@cite]) are emitted as Link { kind: "cite" } in-place. Citation resolution is the consumer's responsibility.
9.5Derived Structure
Parsing (Phases 1–4) produces a flat block sequence — one for the document root, and one for the child list of every block container (ListItem, TaskItem, QuoteBlock, NamedBlock, SpoilerBlock). Section nesting and the Page[] division are not parsed; they are derived from these flat sequences by two deterministic folds: the sectionization fold (§9.5.1) and the pagination fold (§9.5.2).
Implementation neutrality. The folds define the resulting tree, not an implementation strategy. A parser MAY interleave fold logic with block classification, run the folds as separate post-passes, or use any other strategy — it conforms as long as it produces the same tree.
9.5.1Sectionization fold
The sectionization fold applies independently to every flat block sequence (root and each container child list).
A Section spans from its heading to the next heading of level ≤ its own within the same sequence, or to the sequence's end. Equivalently, walking the sequence in document order:
- On a heading of level
n: close all open Sections of level ≥nwithin this sequence, then open a newSection(level=n). - All subsequent non-heading blocks belong to the innermost open Section.
- All open Sections close at the end of the sequence. Section scope never crosses a container boundary.
Skipped levels. A heading whose level is deeper than the innermost open Section by more than one (e.g. = followed directly by ===) nests under the nearest shallower open Section. The written level is preserved in the Section node; no intermediate Sections are synthesized; no diagnostic is emitted. The written level is the source of truth — tree depth is incidental, and consumers that need a normalized depth derive it themselves.
Section attributes are those on the heading line only. Rule B (§6) never assigns a scope-chain slot to a Section.
9.5.2Pagination fold
The pagination fold applies only to the root sequence — blocks inside containers never affect pagination regardless of their type. Two items drive the fold: Meta blocks and PageBreakers (§9.6).
-
The document begins with
Page[0], initially empty (meta: null,children: []). -
A PageBreaker unconditionally closes the current Page — as a Ghost Page if it is empty — and opens a new empty Page. A PageBreaker also closes all open root-level Sections. Every PageBreaker produces a page boundary: a leading
---at document start yields a leading Ghost Page; consecutive separators yield Ghost Pages. -
A
Metablock is handled by the current Page'smetaslot, not by its position in the document.- Slot empty → the
Metafills it. No new Page is created, whatever content the Page already holds. - Slot already set → the
Metacloses the current Page and opens a new Page carrying itself asmeta.
Content before a
Metatherefore never creates a Page: aMetaafter paragraphs, comments, or any other block still fills the Page those blocks are on. AMetaafter a PageBreaker fills the Page the PageBreaker opened — the boundary is the PageBreaker's doing, and theMetaadds none of its own. - Slot empty → the
-
All other root blocks are appended to the current Page's
children. -
Ghost Pages (
meta: null,children: []) are valid and emitted as-is. Consumers decide how to handle them.
9.6PageBreaker
A PageBreaker is the pagination signal consumed by the fold in §9.5.2. It produces no AST node. The construct — its syntax, its diagnostics, and its behaviour inside block containers — is defined in §4.10.
9.7Incremental availability
Incremental availability does not create a second parsing mode. At every decoded Unicode-scalar boundary, an implementation MUST produce the same AST and diagnostics as an ordinary parse of the source available at that boundary. Later source MAY reinterpret an unresolved inline suffix, an open block/container, or derived structure that depends on it. Consumers MAY delay semantic rendering until an end of block (§1, §16); Cutdown defines no rendering schedule.
10Block Structure and Block Boundaries
10.1Block Boundaries
Blocks are separated by one or more blank lines. A blank line is a line containing only whitespace characters (under the interpretive rules of §7); consecutive blank lines act as one (§12.1).
A parser identifies block boundaries by scanning for blank line sequences. Each contiguous run of non-blank lines is classified by its first line (§9.3). A run does not always produce one block:
- A single-line block —
Section,FileRef,ImageBlock,RefDefinition, a table row — consumes its own line. The rest of the run re-enters classification, so= Titlefollowed directly bycontentyields aSectionand aParagraph. - A
Paragraphconsumes the whole run: it ends at the blank line, never before. - A fenced block consumes from its opener to its closer, which may lie past a blank line (§10.4.2).
Block elements cannot interrupt a paragraph. Once a run has been classified as a Paragraph, every later line in it is paragraph content, whatever it looks like — this holds for every block opener without exception, fences included. See §4.1, which defines the rule.
Comments (§2) are detected in Phase 2 before block boundary analysis. A line starting with ### is a CommentBlock fence (produces an AST node). A line starting with ## (pre-## content empty) acts as a blank line for block-boundary purposes and stores its payload as a Reflection entry on the nearest block. See §2 for the full semantics and §10.4.4 for the symbol-repetition table.
10.2Leading and Trailing Whitespace
Any number of leading spaces (including none) are stripped before block classification. Indentation has exactly two uses in Cutdown:
- Block classification ignores it. The stripped line determines the block type.
- List nesting uses it. For a list marker, the parser records the marker's original column (before stripping) as separate metadata and feeds it to the nesting stack model (§10.5). The column is used during list parsing only.
Indented code blocks (as in CommonMark) are not supported.
Trailing-space handling is defined in §12.1.
10.3Block Classification
Each block candidate is classified by its first line (see §9.3 for the full classification table).
10.4Syntax Primitives
Cutdown uses two structural patterns for delimiters:
Philosophy: because the delimiter is always a doubled symbol, every single symbol stays literal.
snake_case, an apostrophe in "don't",2*3, anda_bneed no escaping in ordinary prose. A reader who knows this can predict the rest of the grammar instead of memorising it: one symbol is text, two open an inline segment, three open a block.
10.4.1Doubled-symbol inline delimiter
Any two identical characters form an inline block delimiter:
<Symbol><Symbol> content <Symbol><Symbol> {attrs}
The opener and closer are the same doubled symbol. Inline blocks delimited this way are composable (nestable with other types). The parser recognizes a built-in exclusive list of doubled symbols (see §5). Unrecognized doubled symbols are emitted as literal text.
10.4.2Tripled-symbol block delimiter
Any three identical characters form a block delimiter:
<Symbol><Symbol><Symbol>[name] {attrs}
content
<Symbol><Symbol><Symbol>
The opener may carry an optional name and attributes. The closer is the bare tripled symbol. The parser recognizes a built-in exclusive list of tripled symbols (see §4). Unrecognized tripled symbols are emitted as literal text.
10.4.3Delimiter placement
Doubled-symbol delimiters may appear:
- Surrounded by spaces:
aa ** bb ** cc - Adjacent to literal text on one or both sides:
aa**bb**cc - Adjacent to another delimiter:
__**text**__
In all cases the delimiter is recognized. Whether an unmatched opener is treated as literal text follows the same rule as all inline constructs (§5, §9.4).
10.4.4Symbol repetition
When N identical characters appear at an inline position, the following rules apply:
Inline delimiter symbols (inline parsing context):
| Symbol | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
* |
literal | Strong open/close |
** + * literal |
Strong([]) empty |
Strong([]) + * literal |
_ |
literal | Emphasis open/close |
__ + _ literal |
Emphasis([]) empty |
Emphasis([]) + _ literal |
" |
literal | QuoteInline(double) open/close |
"" + " literal |
QuoteInline([]) empty |
QuoteInline([]) + " literal |
' |
literal | QuoteInline(single) open/close |
'' + ' literal |
QuoteInline([]) empty |
QuoteInline([]) + ' literal |
| ` | literal | CodeInline open/close |
`` + ` literal¹ | CodeInline("") empty |
CodeInline("\")` |
~ |
literal | Highlight open/close |
~~ + ~ literal¹ |
Highlight([]) empty |
Highlight([]) + ~ literal |
$ |
literal | MathInline open/close |
$$ + $ literal¹ |
MathInline("") empty |
MathInline("$") |
^ |
literal² | Spoiler open/close |
^^ + ^ literal¹ |
Spoiler([]) empty |
Spoiler([]) + ^ literal |
# |
literal | ## line comment → Reflection entry (to EOL, no closer) |
CommentBlock fence³ |
CommentBlock fence + # literal |
CommentBlock fence + ## literal |
¹ When appearing at the start of a block line, ```, ~~~, $$$, ^^^ are block fences (CodeBlock, Meta, MathBlock, SpoilerBlock respectively). In inline context, they parse as 2-delimiter + 1 literal.
² A single ^ (caret) is literal in inline context. Inside a [...][^id] link/definition target slot it retains its reference-marker role (§4.14, §5.5); that role is delimited by the surrounding brackets and never reaches the Spoiler parser.
³ ### is a block fence only when it begins a block candidate — that is, when it is the first non-whitespace content of a line after container-indent stripping (§10.2, §10.6). In inline position, ### parses as ## (line comment opener — runs to EOL) + # (collapsed into the payload text).
Block/structural symbols:
| Symbol | 1 | 2 | 3 | 4+ |
|---|---|---|---|---|
= |
Heading L1 (+space) | Heading L2 | Heading L3 | … up to L9; 10+ = literal |
> |
QuoteBlock L1 | QuoteBlock L2 | QuoteBlock L3 | Level N (no limit) |
- |
list marker (- +space) or literal |
literal -- |
PageBreaker (top level; no node — §9.6) | PageBreaker (tail dropped, CDN-0016) |
: |
literal | Mark ::name … :: (§5.10) |
NamedBlock prefix :::name |
literal |
^ |
Caption line when followed by a space (^ text, §6.2) |
inline Spoiler opener — not a block |
SpoilerBlock fence¹ | — |
| |
Table row (§4.8) | — | — | — |
/ |
FileRef when followed by a path (§4.11) | — | — | — |
The two halves of this table use different keys. Rows for =, >, : and # are keyed by run length. Rows for -, ^, \| and / are keyed by the character that follows the symbol at column 1 — a space, a cell, or a path — not by repetition.
Paired symbols ({}/[]) follow their own rules and are not covered by this table; this includes the bracket-initiated block openers ![ (ImageBlock, §4.9) and [^ (RefDefinition, §4.14).
10.4.5Delimiter collisions
When N identical characters appear and the parser recognizes a delimiter of length 2 or 3 at that position, the maximum recognized length is consumed as the delimiter. Any remaining characters are treated as literal content.
Known collisions:
| Sequence | Parsed as |
|---|---|
~~~ at inline position |
~~ (Highlight opener) + ~ (literal) |
$$$ at inline position |
$$ (MathInline opener) + $ (literal) |
| ``` at inline position | `` (CodeInline opener) + ` (literal inside) |
""" at inline position |
"" (QuoteInline double opener) + " (literal) |
''' at inline position |
'' (QuoteInline single opener) + ' (literal) |
^^^ at inline position |
^^ (Spoiler opener) + ^ (literal) |
### at inline position |
## (line comment → Reflection entry; trailing # is part of the payload text) |
--- non-line-start |
literal text (PageBreaker only recognized at top-level line start) |
10.5List Indentation Model
Cutdown uses a stack-based, column-relative model for all list types (unordered, ordered, task). Nesting is determined by comparing marker columns, not by fixed indentation increments.
Definitions:
- The column of a line is its count of leading spaces before the first non-space character. Recorded from the original source before leading-space stripping (§10.2).
- The parser maintains a nesting stack of
(col, item)pairs representing the currently open items from outermost to innermost.
New marker at column C (pop-then-push rule):
- While the stack is non-empty and
C ≤ top.col: pop. - Push the new item at column C.
A marker with C > top.col is a nested child (+1 depth). A marker with C ≤ top.col closes items until a shallower ancestor is found, then opens a sibling.
Non-marker line (continuation text) at column C:
- While the stack depth ≥ 2 and
C < second-from-top.col: pop. - Continue the now-current item.
Depth-0 items (no parent on the stack) accept any non-blank non-marker line unconditionally (threshold = −∞).
Blank lines:
- Blank line followed by content at col 0 → block boundary. The current
Listsegment ends. If the next line is a list marker, a newListnode begins. - Blank line followed by content at col > 0 → absorbed by the list parser. The stack persists. A list marker continues the list via the pop-then-push rule; a non-marker line becomes block content inside the current item (
ListItem.childrenbecomesBlock[]). TheListis markedloose: true.
Style note: Two spaces of indentation per nesting level is recommended. The parser accepts any positive column delta as a valid nesting step; the stack model resolves all cases unambiguously.
Input (standard):
- item 0.0
- item 1.0
- item 1.1
- item 1.2
AST:
List { kind: "bullet", loose: false }
├── ListItem { Text("item 0.0") }
└── ListItem { Text("item 1.0") }
└── List { kind: "bullet", loose: false }
├── ListItem { Text("item 1.1") }
└── ListItem { Text("item 1.2") }
Input (loose list — absorbed blank line):
- First item
continues here
- Second item
This starts a new paragraph inside item two.
AST:
List { kind: "bullet", loose: true }
├── ListItem
│ └── Text("First item continues here")
└── ListItem
├── Paragraph("Second item")
└── Paragraph("This starts a new paragraph inside item two.")
10.6Container-Edge Blank Lines
Leading and trailing blank lines (lines containing only whitespace per §10.1) are stripped from the body of non-opaque block containers before their children are parsed. No diagnostic is emitted.
Applies to:
Does NOT apply to opaque containers — their bodies are captured verbatim:
Pipeline order:
- Document-edge blank-line strip (§7 step 6).
- Block classification and container body extraction.
- Container-edge blank-line strip (this section).
- Indent-base detection (NamedBlock / SpoilerBlock — §4.13 / §4.15). The "first content line" that establishes the base indentation is the first non-blank content line after edge-trim.
- Child parsing.
A container whose body is empty after edge-trim produces children: [] with no diagnostic. The container itself is preserved — it is a deliberate author construct.
Input:
:::note
content
:::
AST:
NamedBlock { name: "note", children: [Paragraph([Text("content")])] }
Input:
:::note
:::
AST:
NamedBlock { name: "note", children: [] }
For QuoteBlock, a "blank line in the body" is a line whose >-stripped content is empty (e.g., > alone, or > alone). Such lines at the leading or trailing edge of the quoted body are stripped before child parsing.
11Precedence Rules
When multiple constructs compete for the same input, the following priority applies (highest first):
| Priority | Construct | Notes |
|---|---|---|
| 1 | CodeBlock fence ``` |
Content always literal |
| 2 | MetaBlock fence ~~~ |
Content always literal |
| 3 | MathBlock $$$ |
Content always literal |
| 4 | CommentBlock ### |
Content always literal (opaque). See §2.3, §4.16 |
| 5 | Inline code `` CodeInline |
Content literal |
| 6 | Line comment ## |
No closer; runs to EOL. Payload stored as Reflection entry on enclosing block. Acts as the terminator for any open inline constructs, which degrade per their class (§9.4.1) — Class 2 openers emit the verbatim slice up to the ## cut. Not recognized inside CodeInline / MathInline / quoted attribute values. See §2.2 |
| 7 | Escape \x |
Resolved before delimiter matching |
| 8 | Links and images [...](...) |
Matched before emphasis runs |
| 9 | Inline math $$ |
Matched before emphasis; content is literal |
| 10 | Strong **, Emphasis __, Highlight ~~, Spoiler ^^, QuoteInline "" '' |
Source order, greedy |
| 11 | Mark ::name … :: |
Matched after emphasis. The name run is lexed before delimiter matching and is independent of this table — see Name lexing in §5.10 |
| 12 | Variable {{key}} / Attributes {...} |
Longest opener wins ({{ before {), then source order |
Note: MathInline content is always literal (no inline parsing). MathBlock content is always literal.
12Whitespace Rules
12.1Whitespaces in Block Segments
| Situation | Rule |
|---|---|
| Line endings | \r\n, lone \r, and \n all read as line terminators (§7); the text is not rewritten |
| Encoding | UTF-8 required |
| Trailing spaces | Collapsed to a single space. That space is preserved before a soft break — it becomes Text(" ") in the AST, serving as an explicit word-boundary separator — and dropped at a block boundary — a blank line, end of input, or the end of a single-line block's inline context (a heading line, a table cell, a caption line) |
| Leading spaces on block line | Stripped before block classification |
| Leading spaces on paragraph continuation line | Stripped before inline parsing |
| Multiple blank lines | Treated as a single blank line |
| Tabs outside fenced blocks | Treated as a single space for classification (§7); the text is not rewritten |
| Tabs inside code/meta/math fences | Preserved literally |
| Blank lines inside code/math fence | Preserved literally in content string |
| Blank lines inside meta fence | Passed through in raw string |
| Soft break (single newline in paragraph) | Folded to zero — no character emitted, no AST segment; lines concatenate directly |
LineBreak (\ at line end) |
Produces a LineBreak segment — a line break inside the paragraph, not a block boundary (§5.13) |
Whitespace immediately preceding a {attr} block that is consumed by an attribute slot (block-opener last-attr, inline attachment, or scope-chain) |
Stripped from the preceding text value. Does NOT apply when {...} falls through to literal Text("{...}") per §6.1.3 (orphan). |
12.2Whitespaces in Inline Segments
Within any inline block (Emphasis, Strong, Highlight, Spoiler, MathInline, QuoteInline, Mark):
| Situation | Rule |
|---|---|
| Whitespace between two adjacent opening delimiters (nesting context) | Consumed |
| Whitespace between opening delimiter and first literal | Consumed |
| Whitespace between last literal and closing delimiter | Consumed |
| Whitespace between closing delimiter and next sibling opening delimiter | Preserved as Text(" ") |
| Interior whitespace runs | Collapsed to one space |
Non-breaking space (\u00A0) |
Always preserved, never collapsed |
CodeInline is exempt from whitespace collapsing — boundary stripping and interior run collapsing do not apply. The paragraph-level soft-break rule (single \n → zero) also applies: a CodeInline spanning two lines of a paragraph has the newline removed with no replacement in value. For multi-line code, use CodeBlock (§4).
Examples:
__ bb __ → Emphasis([Text("bb")])
__ text __ → Emphasis([Text("text")])
__ __ → Emphasis([])
aa__bb__cc → Text("aa") + Emphasis([Text("bb")]) + Text("cc")
** __ bb __** → Strong([Emphasis([Text("bb")])]) (space between ** and __ = zero)
** bb ** __ cc __ → Strong([Text("bb")]) + Text(" ") + Emphasis([Text("cc")])
13Special Character Reference
Escape rules: §8 (general), §8.2 (block-opener escapes), §8.3 (opaque-block closer escapes).
| Character | Name | Role | Escapable |
|---|---|---|---|
= |
equals | Heading marker (line start) | Yes |
# |
octothorpe | ## line comment → Reflection entry (anywhere) / CommentBlock (###, line start) |
Yes |
* |
asterisk | Strong delimiter (**) |
Yes |
_ |
underscore | Emphasis delimiter (__) |
Yes |
~ |
tilde | Highlight (~~) / MetaBlock fence (~~~) |
Yes |
| ` | backtick | Inline code (``) / CodeBlock fence (```) — escapable inside CodeInline as \` |
Yes |
[ |
left bracket | Link/image opener | Yes |
] |
right bracket | Link/image closer | Yes |
( |
left parenthesis | Link URL opener | Yes |
) |
right parenthesis | Link URL closer | Yes |
! |
exclamation mark | Image prefix | Yes |
{ |
left brace | Attribute/variable opener | Yes |
} |
right brace | Attribute/variable closer | Yes |
: |
colon | Mark delimiter (::) / NamedBlock (:::) |
Yes |
- |
hyphen | List marker / PageBreaker (---, top level) |
Yes |
> |
greater-than sign | QuoteBlock marker | Yes |
/ |
slash | File reference (line start) | Yes |
\ |
backslash | Escape character / LineBreak at line end (§5.13) — also processes \` inside CodeInline |
Yes |
| | | pipe | Table cell separator (pipe row) / header separator row | Yes |
^ |
caret | Reference link/definition marker / Spoiler delimiter (^^, ^^^) |
Yes |
$ |
dollar sign | Inline math ($$) / block math ($$$) |
Yes |
" |
double quote | Inline quote delimiter ("") |
Yes |
' |
single quote | Inline quote delimiter ('') |
Yes |
14AST Node (Segment) Reference
14.1Root Segments
| Segment | Fields |
|---|---|
Document |
type: "Document", children: Page[] |
Page |
type: "Page", meta: Meta|null, children: Block[] |
14.2Block Segments
All block segments carry reflection: Reflection[] | null (null when no ## comment is present). See §2.2 for attachment rules.
| Segment | Fields |
|---|---|
Paragraph |
type: "Paragraph", children: Inline[], reflection, attributes |
Section |
type: "Section", level: 1..9, heading: Inline[], children: Block[], reflection, attributes |
CodeBlock |
type: "CodeBlock", language: string = "text", raw: string, caption: Inline[]|null, reflection, attributes |
MathBlock |
type: "MathBlock", raw: string, caption: Inline[]|null, reflection, attributes |
QuoteBlock |
type: "QuoteBlock", children: Block[], caption: Inline[]|null, reflection, attributes |
List |
type: "List", kind: "bullet"|"numbered"|"checklist", start: int|null, loose: bool, children: (ListItem|TaskItem)[], reflection, attributes |
Table |
type: "Table", rows: Row[], columns: Column[], caption: Inline[]|null, reflection, attributes |
ImageBlock |
type: "ImageBlock", alt: Inline[], src: string, caption: Inline[]|null, reflection, attributes |
FileRef |
type: "FileRef", path: string, fragment: string|'', query: string|'', caption: Inline[]|null, reflection, attributes |
FileRefGroup |
type: "FileRefGroup", group: "image"|"video"|"audio", children: (FileRef|ImageBlock)[], caption: Inline[]|null, reflection, attributes |
NamedBlock |
type: "NamedBlock", name: string, children: Block[], caption: Inline[]|null, reflection, attributes |
SpoilerBlock |
type: "SpoilerBlock", children: Block[], caption: Inline[]|null, reflection, attributes |
CommentBlock |
type: "CommentBlock", text: string, reflection — no attributes. Hidden by default (§2.5). |
RefDefinition |
type: "RefDefinition", ref: string, children: Inline[], reflection, attributes |
14.3Inline Segments
| Segment | Fields |
|---|---|
Text |
type: "Text", value: string |
Emphasis |
type: "Emphasis", children: Inline[], attributes |
Strong |
type: "Strong", children: Inline[], attributes |
Highlight |
type: "Highlight", children: Inline[], attributes |
Spoiler |
type: "Spoiler", children: Inline[], attributes |
Link |
type: "Link", kind: "external"|"page"|"tag"|"ref"|"cite", children: Inline[], href: string|'', target: string|'', attributes |
CodeInline |
type: "CodeInline", value: string, attributes |
MathInline |
type: "MathInline", formula: string, attributes |
QuoteInline |
type: "QuoteInline", kind: "double"|"single", children: Inline[], attributes |
ImageInline |
type: "ImageInline", alt: Inline[], src: string, attributes |
Mark |
type: "Mark", name: string, children: Inline[], attributes |
LineBreak |
type: "LineBreak" |
Variable |
type: "Variable", key: string, attributes |
14.4Special Nodes
| Segment | Fields |
|---|---|
Meta |
type: "Meta", format: "yaml"|"toml"|"json" = "yaml", raw: string |
ListItem |
type: "ListItem", children: (Block|Inline)[], attributes |
TaskItem |
type: "TaskItem", checked: bool, children: (Block|Inline)[], attributes |
Column |
type: "Column", align: "start"|"left"|"right"|"center"|"comma"|"decimal" = "start" |
Row |
type: "Row"|"Header", children: Cell[], attributes |
Cell |
type: "Cell", children: Inline[], row: number, column: number |
14.5Synthetic Segments
The AST schema admits nodes that are not producible by parsing. Conforming parsers never emit them; conforming consumers must accept them.
| Segment | Fields |
|---|---|
Fragment |
type: "Fragment", meta: Meta|null, children: Block[] |
Fragment is a container block with no name. Its meta carries the Meta of the source Page it was materialized from, or null. A Fragment is a section-scope boundary and is opaque to the derived-structure folds (§9.5): the sectionization and pagination folds treat it as a single opaque item.
14.6Location Type
Every segment MAY carry a source location, loc?: Loc:
interface Loc {
file?: string // source file identifier, when known
start: number // offset of the segment's first code unit
end: number // offset one past the segment's last code unit (end-exclusive)
}
- Offsets index UTF-16 code units of the raw input file — the text exactly as read, before any interpretation (§7). This mirrors the Language Server Protocol's baseline position encoding and is the native indexing of the reference TypeScript implementation.
- Line/column positions are derived from offsets by consumers; they are never stored.
- Conformance AST comparison ignores
loc. Position correctness is verified by a separate, smaller test set. - Segments the parser synthesises rather than reads carry the
locof the causing construct in the containing file, or nolocat all. AFragmenthas a causing construct — itsFileRefline — and takes thatloc. A padded tableCell(§4.8) has no source range and no causing construct at a position, so it carries noloc. A paddedCellis an ordinary emptyCell, not a distinct node type; it is not listed under Synthetic Segments. - Diagnostics (CDN codes) carry a
locidentifying the triggering source range.
14.7Reflection Type
interface Reflection {
loc: Loc // source range of the ## payload (raw-file UTF-16 offsets, see Location Type)
text: string // ## payload with one leading space stripped, trailing whitespace preserved
}
reflection is typed Reflection[] | null, and is null when no ## comment is present on or adjacent to the block. See §2.2 for attachment rules.
14.8Attributes Type
type Attribute =
| { key: "id", value: string }
| { key: "class", value: string[] }
| { key: string, value: string } // value: "" for bare-key tokens
attributes is typed Attribute[]. A block or inline node with no attributes carries an empty array, never null. Nodes that admit no attributes at all (CommentBlock, Meta, Column, Cell) omit the field entirely.
Ordering: entries appear in source order. Deduplication rules (see §6.1.1) may drop entries before the array is emitted.
15Name and Compliance
The name "Cutdown" is reserved for implementations that fully comply with the official specification.
Modified or extended versions of this specification must not use the name "Cutdown" without clear qualification (e.g., "Cutdown-derived", "Cutdown-compatible").
16Streaming Conformance Profile
16.1Scope
The streaming profile is mandatory semantic conformance for every Cutdown parser. It does not require a streaming parser API, a renderer, or a transport protocol.
A producer may expose source one Unicode scalar value at a time. After each value, the source available so far is an input snapshot (§1) and MUST parse according to the ordinary Cutdown grammar. Parsing that snapshot incrementally MUST produce exactly the AST and diagnostics produced by parsing it afresh.
16.2Input boundary
The profile begins after UTF-8 decoding (§7). A decoder buffers an incomplete multi-byte sequence until it can emit a Unicode scalar value; malformed transport bytes are not Cutdown input. The scalar boundary does not change loc: source locations remain UTF-16 code-unit offsets.
A producer MAY batch scalar values for transport, storage, or implementation. Batching does not change the result required at any scalar prefix.
16.3Snapshot and EOF semantics
Every snapshot is an ordinary valid Cutdown document. End of input is the virtual line terminator described in §7. It may mean a saved file, the source currently available from an open producer, cancellation, or normal completion.
At end of input, existing rules apply without repair:
- unresolved inline openers degrade according to §9.4.1;
- an unclosed fence or container consumes its current body and emits its existing diagnostic;
- no closer, marker, whitespace, normalization, or other source is synthesized. This constrains source text only; synthesising AST nodes where the rules require them — a padded table
Cell(§4.8) — is not source synthesis and is unaffected.
A later scalar may complete an unresolved construct and therefore reinterpret its unresolved suffix. It may also close an open block/container or alter derived section, page, and reference-resolution structure. This is ordinary reparsing of a later snapshot, not a mutation encoded in Cutdown source.
16.4Consumers and end of block
Cutdown has no canonical rendering. A consumer that has the complete source snapshot MAY render it immediately. A streaming consumer MAY defer semantic rendering of incomplete content until an end of block inferred from ordinary syntax: a completed block boundary, a closing fence, or end of input.
An end of block is not a source token, a chunk boundary, or an external stream event. Deferred rendering MUST NOT change the AST or diagnostics required for any input snapshot.
A table is a worked example. Its column count is fixed by the first content row (§4.8, Table shape), so Table.columns is settled the moment that row closes and is never revised as later rows arrive — it is monotonic. A consumer may therefore commit to a column count early. Under a max() rule it could not: a later wider row would retroactively change columns, so consecutive snapshots could legitimately disagree.
16.5Exclusions
This profile defines no chunk envelope, completion event, retry, ordering, replacement, deletion, collaboration, CRDT identity, synchronization state, producer identity, or persistent provenance. Those concerns belong to the producer and consumer integration.
16.6Evidence
A conforming implementation MUST run every Unicode-scalar prefix of each fixture in tests/016-streaming-conformance-profile/, including the empty and full prefixes. For every prefix it MUST demonstrate:
- parsing terminates with an ordinary
Documentresult; - repeated parsing is deterministic in AST and diagnostics; and
- every listed checkpoint matches the fixture’s expected AST/pages and diagnostics.
The fixture schema is defined in tests/README.md.
17Canonical Form
The grammar accepts more than one spelling for the same meaning at a small number of points. Canonical Cutdown is the subset that uses exactly one of them.
This section binds tools that write source — formatters, round-trip editors, generators. Two conforming writers given the same AST produce byte-identical source. It places no obligation on a parser: every non-canonical spelling in the table below remains valid input and MUST continue to parse to the same AST.
Canonical form is not a compliance level (§15) and carries no diagnostic. A tool MAY report a non-canonical spelling as a lint; a parser MUST NOT.
The serialization of a parse result is a different subject, governed by policies/canonical-serialization-policy.md.
17.1Registry
The table is closed. Every point at which two distinct inputs produce the same AST appears here. A spelling that carries a different AST value is not an alias and does not appear — :--- and ---- differ ("left" versus "start", §4.8), so neither is canonical for the other.
| # | Point | Canonical | Also valid as input | Defined in |
|---|---|---|---|---|
| 1 | Meta format tag |
lowercase — yaml, toml, json |
any case | §4.3 |
| 2 | Nested quote marker | > > |
>> |
§4.6 |
| 3 | Ordered list item numbers | consecutive ascending integers from List.start |
any numbers after the first | §4.7 |
| 4 | TaskItem unchecked marker |
[ ] |
— | §4.7.2 |
| 5 | TaskItem checked marker |
[+] |
[x], [X] |
§4.7.2 |
| 6 | Trailing | on a table row |
omitted, where omitting it does not move the attribute scope chain | present | §4.8 |
| 7 | A table with no rows and no columns | | |
a header-separator-only table | §4.8 |
| 8 | Trailing {attrs} placement |
on the block's own line | on following attribute-continuation lines | §6.1 |
| 9 | Block-opener escape placement | backslash before the marker — \``` |
`\ , \` and the equivalents for every other marker |
§8.2 |
| 10 | Blank lines at the start and end of a document | none | any number | §12.1 |
| 11 | Padding inside inline delimiters | none — **bb** |
** bb **, ** bb ** |
§12.2 |
17.2Notes on individual rows
Row 2 — why the spaced form. One space after each > keeps the marker run readable at depth and matches the single space that separates the innermost > from the content: depth three is > > > text, not >>> text. Both spellings carry the same depth (§4.6).
Row 5 — why [+]. The letter x is a strong left-to-right character, so [x] opens a directional run inside the brackets and renders incorrectly in right-to-left source. [+] inherits the surrounding direction and is intact in any script. [x] and [X] stay valid input because they are what a Markdown document pasted into Cutdown carries.
Row 5 — preservation is not available. TaskItem.checked is a boolean and carries no spelling. A writer cannot reproduce the input marker from the AST, so it MUST emit the canonical one.
Row 6 — not always an alias. A trailing | after the last cell changes which slot a following {attrs} fills (§4.8). Where it does, it is load-bearing and the row does not apply.
Row 7 — a writer must still pick one. Table { rows: [], columns: [] } is produced both by a lone | and by a table whose only line is a header separator. The separator carries alignment for columns that do not exist, so the shorter form is canonical.
17.3What is not in the registry
Three classes of input variation look like aliases and are not:
- Recovery output. An unclosed fence produces the same AST as a closed one plus a diagnostic (CDN-0001 … CDN-0005). The diagnostic is the difference; there is nothing to canonicalize.
- Diagnosed surplus.
-----parses as a PageBreaker with its tail dropped and CDN-0016 emitted (§4.10).---is the only undiagnosed spelling, so it is the only one a writer can emit. - Layout. Line width, blank-line counts, table column padding, and indentation width are a tool's style settings. They are outside this section and outside the spec.