Cutdown Markup Language Specification


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

  1. Conventions — Identifier charset, Segment, Block type, Inline type
  2. Comments## line comment (Block.Reflection), CommentBlock (###)
  3. Document Model — Document, Page
  4. Block Segments — Paragraph, Section, Meta, CodeBlock, MathBlock, QuoteBlock, List, ListItem, TaskItem, Table, ImageBlock, PageBreak, FileRef, FileRefGroup, NamedBlock, RefDefinition, SpoilerBlock, CommentBlock
  5. Inline Segments — Text, Emphasis, Strong, Highlight, Spoiler, CodeInline, TextBreak, Link, ImageInline, Span, MathInline, Variable, QuoteInline
  6. Universal Attributes
  7. Input Interpretation
  8. Escaping
  9. Parsing Algorithm
  10. Block Structure and Block Boundaries
  11. Precedence Rules
  12. Whitespace Rules
  13. Special Character Reference
  14. AST Node (Segment) Reference
  15. Name and Compliance
  16. Streaming Conformance Profile — decoded-character snapshots, end-of-block semantics, and profile evidence

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 in words on first use in a section — "a single # (octothorpe)", "the caret (^)" — and thereafter by symbol alone. §13 is the register of names.

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, span names, code language tags, reference definition IDs, and variable keys. It is ASCII-only and case-sensitive. Matching against ID_LITERAL is always case-sensitive unless explicitly stated otherwise.

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

Container blocks carry children: (Block | Inline)[]. 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
    | Link
    | CodeInline
    | MathInline
    | QuoteInline
    | ImageInline
    | Span
    | TextBreak

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


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 # (octothorpe) 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 ## (octothorpe) 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 \n is the comment payload. The ## and one leading space (if present) are stripped; the remainder is the text value 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 \n blindly. It does NOT honour link-text ], table cell |, attribute }, 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, Meta content, or CommentBlock content. 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.) follows the same scope-local rule, attaching to the preceding sibling block within 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 ### at the same column, 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 ### at the same column as the opener.
  • Content between opener and closer is opaque — captured as a raw string with no inline or block parsing. The \n between content lines is preserved; a single trailing \n is appended.
  • ### is recognized at Page scope AND inside containers (ListItem, TaskItem, QuoteBlock, NamedBlock, SpoilerBlock), following the same column rules as other tripled-fence blocks (§9.2.4, §10.5).
  • Unclosed ### consumes to end-of-document and emits a CommentBlock with 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 open CodeBlock, MathBlock, or Meta body 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 \X is 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

CommentBlock is a pass-through node for Page Assembly (§9.6). It never triggers a new Page and never consumes a Meta slot. A CommentBlock appearing before any other block on a Page does not prevent a later Meta from being assigned to that Page's meta.

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 commentSection.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 | {}
  children: Block[]
}

Pages are not parsed — they are derived from the root block sequence by the pagination fold (§9.5.2). In summary:

  • The initial Page is always present at document start, even if empty.
  • A PageBreak (top-level ---, §9.6) unconditionally closes the current Page — as a Ghost Page if empty — and opens a new one. It produces no node.
  • A Meta block closes the current Page and opens a new Page carrying it as meta, unless it is the first pagination-relevant item of the document, in which case it fills the initial Page's meta.

Ghost Pages (meta: null, children: []) are valid and emitted as-is. Consumers decide how to handle them.


See Section in §4 Block Segments.

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. Once a paragraph begins, no block element can interrupt it — it continues until a blank line.

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.
  • \ at line end produces a TextBreak segment (explicit line break).

Example:

Input:
  First line
  second line\
  third line

AST:
  Paragraph
  ├── Text("First linesecond line")
  ├── TextBreak
  └── 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 contains all subsequent blocks until a heading of equal or lesser level, end of the current block container, or end of document.

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[].
  • 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). Scoping follows the same level logic but is bounded by the container — never crosses container boundaries.
  • Opener escape: \= at line start suppresses heading formation at any level — \=, \==, \=== ... all become Paragraph([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 \n is appended.
  • Always fills Page.meta. If Page.meta is already set, opens a new Page first. Never appears in Page.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 attributes field.
  • Closer escape: \~ inside the body emits a literal ~ (consumes the \). A line \~~~, ~\~~, or ~~\~ therefore does NOT close the fence. All other \X sequences 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 \n appended. 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 ` (consumes the \). A line \```, `\ , or \` therefore does NOT close the fence. All other \X sequences are literal (including \\ → two chars). See §8.3. Opener escape: see §8.2.
  • Supports caption line (§6.2). A ^ text line immediately after the closing fence (no blank line) sets caption: 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 \n appended. 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 a CodeBlock or split the math). See §8.3. Opener escape (\$$$): see §8.2.
  • Supports caption line (§6.2). A ^ text line immediately after the closing fence (no blank line) sets caption: 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
}
  • Every line MUST begin with >. No lazy continuation — a line without > ends the quote.
  • In same time QuoteBlock supports trailing lines without > in same way as Paragraph.
  • 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.
  • 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 attribution line (§6.2). A ^ text line immediately after the closing line (no blank line) sets attribution: Inline[] on this node.

Examples:

Input:
  > Line 1
  > Line 2
  > Line 3

AST:
    QuoteBlock
    └── Paragraph { children: [Text("Line 1 Line 2 Line 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 1 Line 2 Line 3")] }
    Paragraph { children: [Text("Line 4")] }

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[]
  caption: Inline[] | null
  reflection: Reflection[] | null
}
  • Unordered marker: - followed by one space. Only - is supported.
  • Ordered marker: {number}. followed by one space. Only . delimiter; ) is not supported. Actual numbers are ignored except for start.
  • kind is determined by the first item's marker: -"bullet", {n}."numbered", - [ ]/- [x]/- [+]"checklist".
  • start is non-null only for kind: "numbered".
  • Tight vs loose: A list is loose: true when a blank line appears between items within the list scope. loose is 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 List segment.
  • 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: children becomes Block[] (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. [+] is bidi-neutral and MAY be used in place of [x]/[X] where RTL content would otherwise reorder the Latin letter within the brackets.
  • Only kind: "bullet" list items may carry a checkbox. A kind: "numbered" list encountering a task marker closes and a new kind: "checklist" List segment opens.
  • A List with kind: "checklist" has children: TaskItem[] exclusively; kind: "bullet" and kind: "numbered" have children: ListItem[] exclusively.
  • Mix of item types introduces a new list boundary: the first item of the new type starts a new List segment.
  • 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

Cutdown supports two table variants, distinguished by the first line.

Pipe table (kind: "pipe"): First line starts with |. Standard Markdown (GFM) pipe tables parse unchanged.

| Cell A | Cell B |                        ← no-header table

| Name   | Score |                         ← pipe table with header
|:-------|------:|                         ← header separator; left / right align
| Alice  |    42 |
| Bob    |    17 |

Multiline table (kind: "multiline"): First line starts with +-.

+-                                         ← minimal opener (single-row table)
| single row |

+----------+----------+                    ← full grid
| Header A | Header B |
|:---------|----------|                    ← header separator; left-align col 0
| Cell A   | Cell B   |
+----------+----------+

AST type:

interface Table {
  type: "Table"
  kind: "multiline" | "pipe"
  rows: Row[]
  columns: Column[]
  attributes: Attribute[] | null
  caption: Inline[] | null
  reflection: Reflection[] | null
}

interface Row {
  type: "Row" | "Header"
  children: Cell[]
  attributes: Attribute[] | null
}

interface Cell {
  type: "Cell"
  children: Inline[] | Block[]   // Inline[] when Table.kind is "pipe"; Block[] when "multiline"
  row: number                    // zero-indexed position in Table.rows[]
  column: number                 // zero-indexed
}

interface Column {
  type: "Column"
  align: "left" | "right" | "center" | "comma" | "decimal"  // default: "left"
}

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 follows the row grammar of its table kind. 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.

In a multiline table a header separator is a full separator row: it closes the current logical row and defines column boundaries exactly like a + separator row, in addition to marking the preceding section as Header.

A + separator row never marks headers. Colons appearing in a + row are inert — they have no effect. By kind:

  • Pipe: + rows are ignored entirely — no structural effect, colons and {attrs} included.
  • Multiline: + rows delimit logical rows and body sections (preceding section stays type: "Row").

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"
---- "left" (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 defaults to "left".


Table shape

These rules govern both kind: "pipe" and kind: "multiline".

Leading | required. Every content row and header separator opens with |. It is the detection anchor (§9.3 classifies a pipe 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 (§4.8, Attrs scope chain (multiline)), so it never defines the count.

  • A later row with fewer cells is padded with empty Cell nodes to the column count. No diagnostic — nothing is lost, this is normalisation. Padded cells carry no loc (§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 default to "left" 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.

Pipe table specifics

  • Row shape — leading |, optional closer, column count — follows Table shape above.
  • Each | content line is one independent logical row.
  • Cells contain Inline[] parsed by full inline rules.
  • A + row between pipe rows is ignored — no structural effect, colons included.

Attrs scope chain (pipe). Rule B (§6) applies. The Table slot is only available from the last content row's chain; mid-table rows start at Row. Because Cell bears no attributes, the chain walks past the last cell to the last attr-bearing inline inside it — but only if that cell is still open. Writing the closing | seals the cell's inline context before the chain begins, removing 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. 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. + rows do not participate in the pipe scope chain (they are ignored entirely).

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"))

Mid-table row:
| td1 | td2 | {.a}           →  Row({.a}, ...)                  ← sealed: 1 slot only
| AA | **BB** {.a}{.b}       →  Row({.b}, Cell(...), Cell(Strong({.a}, "BB")))

Multiline table specifics

Opener. A line starting with +- opens a kind: "multiline" table. Content on the opener line after +- (including {attrs}) is treated as part of the opener row's attrs — see Attrs scope chain below.

Row boundaries. All | content lines between two consecutive separator rows (+ rows or header separators) form one logical row. A table with only one separator (the opener, no further separator rows) produces one logical row from all subsequent | lines until the table ends (blank line or container boundary).

Multi-line cells. When multiple | lines belong to one logical row, each column's content strips are joined with a single space between lines. (Note: this differs from paragraph continuation, where a soft break folds to zero, §12 — cell strips are column slices, so the explicit separator is required.) \ at end of a content line produces a TextBreak segment in that cell.

Column boundaries. Both the column count and the boundary positions come from the | positions of the first content row. Column count follows Table shape above.

+ rows are decorative for column purposes — they neither count columns nor position boundaries. A +---+---+ drawn wider or narrower than the first content row is inert; no diagnostic is emitted, because decoration should not be diagnosable. + rows keep their other jobs: opening the table (§9.3), delimiting logical rows, carrying {attrs} to the Table slot, and marking body sections.

Trailing |. Optional, per Table shape above. Omitted, the last column extends to end of line.

Cell content — Block context. Multiline cells are parsed as Block[] (full block context: paragraphs, headings, lists, nested tables, named blocks, etc. — same rules as ListItem).

Per-column blank-line detection: a cell line is considered blank when its content slice (after stripping leading and trailing whitespace within the column width) is empty. Each column's blank lines are detected independently.

Leading and trailing whitespace is stripped from each line slice within a column before block parsing.

Attrs scope chain (multiline). {attrs} on a separator row (+ row or header separator) → Table (last separator row with attrs wins). {attrs} on a | content row → Row. A header separator is a separator row for attr purposes, never a content row.

The chain stops at Row and never gains an inline slot, unlike the pipe chain. This follows from the cell content model, not oversight: a multiline cell holds Block[], not Inline[], so there is no inline context for a slot to bind to. Do not "align" this with the pipe chain.

+----------+ {.tbl}      →  Table({.tbl})
| cell | {.row}          →  Row({.row})

Empty tables

A single | or +- line with no cell content is a valid empty table:

+-           →  Table { kind: "multiline", rows: [], columns: [] }

+- {#id}     →  Table { kind: "multiline", rows: [], columns: [], attributes: [{id:"id"}] }

|            →  Table { kind: "pipe", rows: [], columns: [] }

| {.tbl}     →  Table { kind: "pipe", 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, header separator, or + separator row) 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 ^ text line immediately after the table's last line (no blank line) sets caption: Inline[].
  • Escaping: \| at line start → Paragraph (suppresses a pipe row or header separator); \+ at line start → Paragraph (suppresses multiline opener or separator). See §8.2.

4.9ImageBlock

Syntax:

![alt text](src) {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 ImageInline syntax and parsing details. The same rules apply to ImageBlock alt text and src.
  • Consecutive ImageBlock lines with no blank line between them are wrapped in a FileRefGroup { group: "image" }.
  • ImageBlock is the block-level counterpart of ImageInline (§5). The difference is that ImageBlock must 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 a Paragraph containing an ImageInline followed by the remaining inline content. No content is dropped.
![a](b)                  → ImageBlock { alt: [Text("a")], src: "b" }
![a](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 ^ text line immediately after this line (no blank line) sets caption: Inline[] on this node. ImageInline does not support captions — it is not a block.

4.10PageBreak

Syntax:

---

A top-level line beginning exactly ---. A PageBreak 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 PageBreak — it parses as Paragraph([Text("---")]) and CDN-0017 is emitted. Glued to a preceding paragraph, --- is ordinary paragraph content (no diagnostic).
  • Opener escape: \---, -\--, or --\- at top level → Paragraph([Text("---")]); no page break occurs. See §8.2.
  • Cutdown performs no front-matter detection: a document-leading --- is a PageBreak 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[]
  reflection: Reflection[] | null
}
  • path starts with / and uses wide range of characters, except < > : " \ | * { } and whitespace. The first space (if any) separates the path from {attrs}.
  • Fragment (#): everything from the first # to the next space (or end of line, before {attrs}) is extracted as fragment. src stores the portion before #.
  • Query (?): if path contains ?, the parser extracts the query string as query. src stores the portion before ?.
  • fragment and query are mutually independent — either, both, or neither may be present. If absent, they are set to empty string '' (not null).
  • Empty path (line with only /) is invalid state and produces string literal for whole line.
  • group is set automatically by file extension (see Known Groups below). Consumers may configure the extension lists.
  • Opener escape: \/path at line start → Paragraph([Text("/path")]). See §8.2.
  • Supports caption line (§6.2). A ^ text line immediately after this line (no blank line) sets caption: Inline[] on this node. If the FileRef is part of an active FileRefGroup, 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 FileRefGroup segments.
  • Unknown-extension files are never grouped.
  • Supports caption line (§6.2). A ^ text line immediately after the last member of the group (no blank line) sets caption: Inline[] on the FileRefGroup. A ^ line mid-run closes the group at that point; the next FileRef/ImageBlock starts 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 an ID_LITERAL character 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 ^ text line immediately after the closing ::: (no blank line) sets caption: 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
}
  • ref uses [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 empty children array.
  • Content is parsed by inline rules. Result is Inline[].
  • Cutdown does not validate that every [^ref] link has a matching definition.
  • When the same ref is 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 :::spoiler NamedBlock 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; SpoilerBlock has no kind field.
  • 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 ^ text line immediately after the closing ^^^ (no blank line) sets caption: 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
}
  • Opening: bare ### at the container's effective column. 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: a line whose stripped content is exactly ### at the same column as the opener.
  • Content is opaque — captured verbatim with no inline or block parsing. Lines joined with \n; a single trailing \n is 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.
  • CommentBlock is a pass-through node for Page Assembly (§9.6). It never splits Pages, and never consumes a Meta slot.
  • Default render policy is hidden: conforming renderers SHOULD omit it. See §2.5.
  • No attributes field.
  • 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 \X sequences 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
RefDefinition content §4
Children of Emphasis, Strong, Highlight, QuoteInline §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. The only exception is that a \ at the end of a line (before \n) produces a TextBreak segment (§5.13) instead of literal text.


5.2Emphasis

Syntax: __inline content__

AST type:

interface Emphasis {
  type: "Emphasis"
  children: Inline[]
  attributes: Attribute[]
}
  • __ opener and closer. A single _ 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 * is always literal text.
  • Same rules as Emphasis: run of 3, greedy, unclosed = literal, no same-type nesting.
  • Cross-nesting with Emphasis allowed: **__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 ~ 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 Emphasis and Strong allowed.

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
  • text is parsed by inline rules. May be empty.
  • href / target may be empty strings — both are valid and preserved.
  • Page target uses PATH_LITERAL characters. Tag target: # + PATH_LITERAL. Ref target: ^ + ID_LITERAL. Cite target: @ + any non-] characters.
  • 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 href values.

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.

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('""') or Text("''").
  • 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: ![alt](src) {attrs}

AST type:

interface ImageInline {
  type: "ImageInline"
  alt: Inline[]
  src: string
  attributes: Attribute[]
}
  • alt is parsed by inline rules. Result is Inline[]. Despite the alt context 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 in alt (e.g. links, images, formatting) or strip it down to plain text. alt may be empty.
  • src is a raw string. No URL validation or sanitization by Cutdown; consumers may choose to validate/sanitize as needed. src may be empty.
  • ![]() is valid and preserved as ImageInline { alt: [], src: "" }.
  • Captions: ImageInline does not participate in the caption line (§6.2) — it is not a block. Only ImageBlock (§4.9) is captionable.
  • ImageInline vs ImageBlock (§4.9): a line at block level beginning with ![ is classified as an ImageBlock, and an ImageBlock must be the only segment on its line. An image anywhere else — mid-line, or on a line carrying other content — is an ImageInline. See §4.9.

5.10NamedInline (Span segment)

Syntax: ::name {attrs}

An empty inline placeholder/hook for consumer post-processing.

AST type:

interface Span {
  type: "Span"
  name: string  // non-empty string
  children: []  // always empty
  attributes: Attribute[]
}
  • :: followed immediately by a span name ([ID_LITERAL]+), then optional attributes.
  • Always empty — no children.
  • :: without a valid name is emitted as literal Text("::").

Example:

Hello ::marker {#here .highlight} world
→ Text("Hello ") + Span { name: "marker", attributes: {id:"here", class:["highlight"]} } + Text(" world")

5.11Variable

Syntax: {{key}}

AST type:

interface Variable {
  type: "Variable"
  key: string
  attributes: Attribute[]
}
  • key MUST use ID_LITERAL characters: [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, and Link is 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.13TextBreak

Syntax: \<EOL>, backslash as the last character of a line (before \n).

AST type:

interface TextBreak {
  type: "TextBreak"
}

A TextBreak 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 TextBreak breaks the line and keeps the block.

Cutdown emits the AST node TextBreak; 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, unless rest of the line is whitespaces followed by ## (which consumes the rest of the line as a reflection entry). See §2.2.

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 TextBreak 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 Reflection entry 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[] | null — see §14 Attributes Type for the definition.

6.1.1Syntax

{#id .class key=value key="value with spaces"}

Token types inside {}:

  • #identifier — sets the id attribute. Emits { key: "id", value: "identifier" }. First # wins; any subsequent #id or id= token is dropped and CDN-0020 is emitted.
  • .classname — appends to the class entry. All .classname tokens in a block are collected into a single { key: "class", value: string[] } entry. .class syntax has priority: if class= 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, no =) — flag token. Emits { key: "key", value: "" }. Signals a semantic hint with no associated value.
  • Token order inside {} is preserved in the emitted Attribute[].

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: has attributes.
  • 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 { has no matching } 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
Pipe table row — mid-table, cell open Row last attr-bearing inline
Pipe table row — mid-table, cell sealed Row
Pipe table row — last row, cell open Table Row last attr-bearing inline
Pipe table row — last row, cell sealed Table Row
Multiline table row 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 |:

  • 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 at Row.

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

Multiline table rows stop at Row and never gain an inline slot. This is a consequence of the cell content model, not an oversight: a multiline cell holds Block[], not Inline[] (§4.8), so there is no inline context for a slot to bind to. Do not "align" this with the pipe chain.

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}, Span({.a})))
- ::sp {.a}{.b}      →  List({.b}, ListItem({.a}, Span()))
- ::sp {.a}          →  List({.a}, ListItem(Span()))
- ::sp {}            →  List({},   ListItem(Span()))     ← {} no-op on List
- ::sp {.a}{}        →  List({},   ListItem({.a}, Span())) ← {} 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 (or attribution, special QuoteBlock case) field. It does not produce a separate AST node.

Syntax:

^ inline-content

A line at block start consisting of ^ 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 if and only if:

  1. That block is captionable (see table below), and
  2. No blank line appears between the block's last line and the ^ line.

One construct between the block and the caption line is transparent — it does not break binding:

  • A trailing {attrs} line (sets the block's attributes; does not emit a node).

Standalone ## comment lines do not break binding either, because they are never emitted as sibling nodes — they attach to the preceding block's reflection (§2.2) and leave the block stream uninterrupted.

| 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 (the caption slot is already filled, or the first ^ line itself had no captionable predecessor) is treated as an orphaned caption → Paragraph + warning CDN-0008.

Orphan conditions (both emit CDN-0008, line becomes Paragraph):

  • No captionable block precedes ^ in the current scope (including ^ as first line in a scope).
  • The immediately preceding captionable block already has a caption (slot filled).
  • A blank line separates ^ from the preceding block.

Scope-local. The "preceding block" is always resolved within the current block scope. 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 attribution: 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 block reflection at the caption line's offset.
  • {{variable}} is allowed — Variable nodes 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 {
    kind: "pipe",
    caption: [Text("Results from the first cohort")],
    rows: [Row(type:"Header",...), Row(type:"Row",...)],
    attributes: null
  }

Input:
  > To be, or not to be.
  ^ William Shakespeare, __Hamlet__, Act 3

AST:
  QuoteBlock {
    attribution: [Text("William Shakespeare, "), Emphasis([Text("Hamlet")]), Text(", Act 3")],
    children: [Paragraph([Text("To be, or not to be.")])],
    attributes: null
  }

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

  1. 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.
  2. 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 loc offsets). Authors SHOULD store files in NFC.
  3. 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.
  4. Null bytes: U+0000 is not valid content. The parser MUST emit U+FFFD (Unicode replacement character) in its place in Text values and MAY emit a diagnostic. U+0000 and U+FFFD are both one UTF-16 code unit, so offsets are unaffected.
  5. Line terminators: \r\n, lone \r, and \n are all line terminators. This is lexer behavior, not a rewrite: in a \r\n file the \r is 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.
  6. 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.
  7. 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 raw value
  8. HTML entities: HTML character references (&amp;, &lt;, &#160;, &nbsp;, etc.) are not decoded. They are emitted as literal Text segments. The parser has no HTML entity table. Consumers rendering to HTML are responsible for deciding whether to re-encode or pass through.
  9. BiDi Control Characters: Unicode bidirectional control characters (e.g., U+200E LRM, U+200F RLM, U+2066U+2069 Isolates) MUST be preserved literally in Text segments. 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")])
PageBreak (§4.10) --- \---, -\--, --\- Paragraph([Text("---")]) — top level only; no page break 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")])
Pipe table row (§4.8) | | cell | Paragraph([Text("| cell |")])
Multiline table opener / separator (§4.8) +- \+-, +\- Paragraph([Text("+-")])

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 a CodeInline from 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. \fence anywhere produces fence. 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 #, 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 a CodeBlock or 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:

  1. 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).
  2. Bounded lookahead. At most one line at block level; at most to end of line at inline level.
  3. Deferred attachment. Structural decisions may be deferred, but deferred decisions only attach or regroup already-built nodes — they never re-parse text. The deferral windows are: one-block emission latency (a caption line or attribute-continuation line may bind to the preceding block), multiline table buffering until the table closes, and open-inline buffering until end of line.

9.1Phase 1 — Input Interpretation

  1. Receive decoded text from the UTF-8 input boundary (§7).
  2. Apply the interpretive rules of §7: \r\n / \r / \n all 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

  1. Split input into lines.
  2. Detect ## boundaries and record Reflection payloads. Walk lines top-to-bottom, maintaining "opaque context" state (inside CodeBlock, Meta, MathBlock, or CommentBlock). Lines inside an opaque context are NOT scanned, except for the opener line (first line of the fence) and the closer line (the closing fence). On all other lines, scan in source order for the first un-escaped ## not occurring inside CodeInline ( ``), MathInline ($$), or a quoted attribute value. If found: characters before ## are the line's structural content; characters from ## to (but not including) \n are the comment payload. Block classification (Phase 3) operates on the pre-## substring. The payload is later attached to the appropriate block as a Reflection entry (§2.2) — it does not enter the inline stream.
  3. Identify block boundaries: a sequence of non-blank (in pre-## content) lines bounded by blank lines (or document start/end) is a block candidate.
  4. Special blocks that override blank-line boundaries:
    • Code fences: ``` opens until the next ``` (or end of document).
    • Meta blocks: ~~~ opens until the next ~~~ (or end of document).
    • Named blocks ::: open until a closing ::: (or end of document).
    • Math blocks: $$$ opens until the next $$$ (or end of document).
    • Spoiler blocks: ^^^ opens until the next ^^^ (or end of document, or end of the enclosing block container). SpoilerBlocks do not nest — see §4.15.
    • Comment blocks: ### opens until the next bare ### at the same column (or end of document). Content is opaque — see §2.3.

9.3Phase 3 — Block Classification

Each block candidate is classified by its first line:

First line matches Block type
^(={1,9}) Heading → Section
^--- PageBreak (top level only; no node — §9.6)
^ ``` ` CodeBlock
^~~~ Meta
^:::[ID_LITERAL] NamedBlock
^| Table (pipe)
^\+- Table (multiline)
^> 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:

  1. Scans for openers (**, __, ~~, ^^, '``', [, ![, ::, {{, "", '', $$).
  2. On finding an opener, scans forward for a valid closer.
  3. If no valid closer is found, the opener degrades. The degradation rule depends on the opener's class (§9.4.1).
  4. Resolves escape sequences \x before delimiter matching.
  5. 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. { opens an attribute scan running to the matching } 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.

:: (Span) belongs to neither class: it has no closer to scan for. If :: is not immediately followed by a valid ID_LITERAL name, it is emitted as Text("::") and parsing continues.

## 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 ] 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:

  1. On a heading of level n: close all open Sections of level ≥ n within this sequence, then open a new Section(level=n).
  2. All subsequent non-heading blocks belong to the innermost open Section.
  3. 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 PageBreaks (§9.6).

  1. The document begins with Page[0], initially empty (meta: null, children: []).
  2. A PageBreak unconditionally closes the current Page — as a Ghost Page if it is empty — and opens a new empty Page. A PageBreak also closes all open root-level Sections. Every PageBreak produces a page boundary: a leading --- at document start yields a leading Ghost Page; consecutive separators yield Ghost Pages.
  3. A Meta block closes the current Page and opens a new Page, assigning itself to the new Page's metaunless it is the first pagination-relevant item of the document (no block, Meta, or PageBreak has been consumed before it), in which case it fills Page[0].meta and no new Page is created. In particular, a Meta block following a PageBreak does not fill the page the PageBreak opened; it closes it as a Ghost Page and opens its own.
  4. All other root blocks are appended to the current Page's children.
  5. Ghost Pages (meta: null, children: []) are valid and emitted as-is. Consumers decide how to handle them.

9.6PageBreak

A PageBreak is a top-level line beginning exactly ---. It is a pagination signal, not a block: it is consumed by the pagination fold (§9.5.2) and produces no AST node.

The rest of the line — surplus hyphens, {attrs}, any other content — is dropped, and a diagnostic is emitted (CDN-0016). There is no attributed form: the entire line after the leading --- is discarded.

Inside a block container, a blank-line-surrounded --- line is not a PageBreak: it parses as Paragraph(Text("---")) and a diagnostic is emitted (CDN-0017) noting that page separation is a top-level construct. A --- line glued to a preceding paragraph remains paragraph content per the no-interrupt rule (§10.1); no diagnostic is emitted.

Cutdown performs no front-matter detection: a document-leading --- is a PageBreak like any other.

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

Multiple consecutive blank lines are treated as a single blank line.

A parser identifies block boundaries by scanning for blank line sequences. Each contiguous run of non-blank lines is a candidate block, then classified by its first line.

Block elements cannot interrupt a paragraph. A new block construct can only begin after a blank line. A line that would otherwise open a block element (a heading, a list marker, a page separator, etc.) is paragraph content if it appears within a run of non-blank lines that began as a paragraph.

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 is never significant for block type detection in Cutdown — but it is significant for list nesting; see §10.5 and the List exception below.

List exception: For list blocks, the parser records the original column of each marker (before stripping) for use in the list nesting stack model (§10.5). Block type detection still uses the stripped line; the column is a separate piece of metadata used only during list parsing.

Indented code blocks (as in CommonMark) are not supported.

Trailing spaces on any line collapse to a single space; that space is preserved before a soft break and dropped at a block boundary (§12).

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, and a_b need 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 ^ 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 (line-start at the container's effective column, per §9.2.4 / §10.5). 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 -- PageBreak (top level; no node — §9.6) PageBreak (tail dropped, CDN-0016)
: literal Span prefix ::name NamedBlock prefix :::name literal
+ Multiline table opener when followed by - or : (+-, +:)
^ Caption line when followed by a space (^ text, §6.2) inline Spoiler opener — not a block SpoilerBlock fence¹
| Pipe table row (§4.8)
/ FileRef when followed by a path (§4.11)

Rows in this table are keyed by run length except -, ^, \|, and /, whose meaning at column 1 depends on what follows — a space, a cell, a path — rather than on 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 (PageBreak 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):

  1. While the stack is non-empty and C ≤ top.col: pop.
  2. 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:

  1. While the stack depth ≥ 2 and C < second-from-top.col: pop.
  2. 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 List segment ends. If the next line is a list marker, a new List node 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.children becomes Block[]). The List is marked loose: 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:

  • NamedBlock (§4.13)
  • SpoilerBlock (§4.15)
  • QuoteBlock (§4.6)
  • ListItem / TaskItem (§4.7)
  • Multiline table Cell (§4.8) — per-column blank-line detection; each column's edge strips independently

Does NOT apply to opaque containers — their bodies are captured verbatim:

Pipeline order:

  1. Document-edge blank-line strip (§7 step 6).
  2. Block classification and container body extraction.
  3. Container-edge blank-line strip (this section).
  4. 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.
  5. 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 Named span ::name Matched after emphasis
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 (blank line or end of input)
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
TextBreak (\ at line end) Produces a TextBreak 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):

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 Named span prefix (::) / named block (:::) Yes
- hyphen List marker / page break (---, top level) Yes
> greater-than sign QuoteBlock marker Yes
/ slash File reference (line start) Yes
\ backslash Escape character / TextBreak at line end (§5.13) — also processes \` inside CodeInline Yes
| pipe Table cell separator (pipe row) / header separator row Yes
+ plus Multiline table opener / row separator (+-, +---+) 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[], attribution: Inline[]|null, reflection, attributes
List type: "List", kind: "bullet"|"numbered"|"checklist", start: int|null, loose: bool, children: (ListItem|TaskItem)[], reflection, attributes
Table type: "Table", kind: "multiline"|"pipe", 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).

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
Span type: "Span", name: string, children: [], attributes
TextBreak type: "TextBreak"
Variable type: "Variable", key: string, attributes

14.4Special Nodes

Segment Fields
Meta type: "Meta", format: "yaml"|"toml"|"json" = "yaml", raw: string
RefDefinition type: "RefDefinition", ref: string, children: Inline[], attributes
ListItem type: "ListItem", children: (Block|Inline)[], attributes
TaskItem type: "TaskItem", checked: bool, children: (Block|Inline)[], attributes
Column type: "Column", align: "left"|"right"|"center"|"comma"|"decimal" = "left"
Row type: "Row"|"Header", children: Cell[], attributes
Cell type: "Cell", children: Inline[]|Block[], row: number, column: numberInline[] when Table.kind is "pipe"; Block[] when "multiline"

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 loc of the causing construct in the containing file, or no loc at all. A Fragment has a causing construct — its FileRef line — and takes that loc. A padded table Cell (§4.8) has no source range and no causing construct at a position, so it carries no loc. A padded Cell is an ordinary empty Cell, not a distinct node type; it is not listed under Synthetic Segments.
  • Diagnostics (CDN codes) carry a loc identifying 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[] | null.

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:

  1. parsing terminates with an ordinary Document result;
  2. repeated parsing is deterministic in AST and diagnostics; and
  3. every listed checkpoint matches the fixture’s expected AST/pages and diagnostics.

The fixture schema is defined in tests/README.md.