Markdown to Dialogue AST Transpiler
Note
Status: implemented. The second pipeline stage: it re-tokenizes the Markdown AST into the Dialogue AST — lines, speakers, speech fragments, game calls, tags, conditions, choices, and control blocks — using only local, syntax-directed recognition.
Table of contents
- Goal and scope
- Ubiquitous language
- Two layers
- The Dialogue AST
- Markdown AST to Dialogue AST mapping
- Key design decisions
- Error and boundary cases
- Testability
Goal and scope
Alice @A #happy: Hi, `"Player.Name"`! => [Leave](#exit)
becomes one Line with a SpeakerDeclaration("Alice", "A", [#happy]) and the
speech Text "Hi, ", Query "Player.Name", Text "! ", JumpIndicator,
Text " ", Link([Text "Leave"], "#exit").
The transpiler decides everything a single node (and the text in it) can decide. Anything that needs neighbors or the whole document is later:
| Deferred work | Stage |
|---|---|
Fold JumpIndicator + Link into a Jump; recognize effect-only lines as ControlLine; fill the default speaker |
Desugar |
| Nest headings into scenes, bind speakers, resolve jump targets, validate reserved tags | Semantic Analyzer |
Ubiquitous language
The whole tree is the Dialogue AST; its root type is ScriptDocument (not
Script, which would clash with the namespace).
| Term | Meaning |
|---|---|
| ScriptBlock | One item of a script or branch body: Line, SceneHeading, ControlLine, ControlBlock, or a choice group. |
| SceneHeading | A heading as a flat token; scenes are built from it later. |
| Line | One utterance: an optional Speaker, its Speech, and an optional Condition. |
| Speaker | Who speaks, unresolved: a declaration, a name or id reference, or a partial declaration. |
| Speech | A line's ordered InlineFragments. |
| GameCall | A game-state hook inside speech: Query, DefaultCommand, or CustomCommand. |
| Condition | A `"key"?` check attached to a line, jump, option, or branch (see Conditions). |
| Choice group | Choices (player picks) or RandomChoices (weighted pick, see Random Choice). |
| ControlBlock | An if / elseif / else blockquote of Branches (see Block Controls). |
| Tag | CustomTag (#name) or ReservedTag (##name), optionally valued (=value). |
Two layers
- Block layer —
BlockBuilderwalks the Markdown blocks. One recursiveBuild(blocks)serves the document body, every choice body, and every branch body.LineBuilderturns one group of inlines into aLine;ControlBlockBuilderturns a blockquote into aControlBlock. - Inline layer — small parsers produce data, and builders turn data into nodes:
| Parser (text → data) | Builder (data → node) | Recognizes |
|---|---|---|
SpeakerPrefixParser |
SpeakerBuilder |
Name @id #tags: |
GameCallParser |
GameCallBuilder |
a code span's game call |
TagParser |
TagBuilder (via InlineLeafBuilder) |
#tag, ##tag, #k=v |
InlineLeafTokenizer |
InlineLeafBuilder |
text runs into Text / Tag / JumpIndicator |
InlineBuilder walks inline content (speech, emphasis children, labels, alt text)
and calls the leaf builders. Condition, weight, and control-marker recognition live
in their own small readers (ConditionReader, ChoiceWeightReader,
MarkerRecognition, RandomChoiceRecognition). The grammar each accepts is the
script-language guide.
The Dialogue AST
classDiagram
class ScriptDocument { Body }
class ScriptBlock { <<abstract>> }
class ChoiceGroup { <<abstract>> }
class Speaker { <<abstract>> }
class SpeakerReference { <<abstract>> }
class InlineFragment { <<abstract>> }
class GameCall { <<abstract>> }
class Tag { <<abstract>> Name; Value }
class ChoiceWeight { <<abstract>> }
ScriptDocument o-- ScriptBlock
ScriptBlock <|-- Line
ScriptBlock <|-- SceneHeading
ScriptBlock <|-- ControlLine
ScriptBlock <|-- ControlBlock
ScriptBlock <|-- ChoiceGroup
ChoiceGroup <|-- Choices
ChoiceGroup <|-- RandomChoices
Choices o-- Choice
RandomChoices o-- RandomOption
RandomOption o-- ChoiceWeight
ControlBlock o-- Branch
Choice o-- ScriptBlock : body
RandomOption o-- ScriptBlock : body
Branch o-- ScriptBlock : body
Line o-- Speaker : optional
Line o-- InlineFragment : speech
Speaker <|-- SpeakerDeclaration
Speaker <|-- PartialSpeakerDeclaration
Speaker <|-- SpeakerReference
Speaker <|-- DefaultSpeaker
SpeakerReference <|-- SpeakerNameReference
SpeakerReference <|-- SpeakerIdReference
InlineFragment <|-- Text
InlineFragment <|-- StyledText
InlineFragment <|-- Image
InlineFragment <|-- Link
InlineFragment <|-- LineBreak
InlineFragment <|-- JumpIndicator
InlineFragment <|-- Jump
InlineFragment <|-- Condition
InlineFragment <|-- Tag
InlineFragment <|-- GameCall
GameCall <|-- Query
GameCall <|-- DefaultCommand
GameCall <|-- CustomCommand
Tag <|-- CustomTag
Tag <|-- ReservedTag
ChoiceWeight <|-- NumberWeight
ChoiceWeight <|-- QueryWeight
ChoiceWeight <|-- AutoWeight
ControlLine, Jump, and DefaultSpeaker appear only after desugar. Every node
is an immutable record with a SourceSpan, except the ScriptDocument root.
StyledText holds a Dialogue-side SpeechStyle (not the Markdown EmphasisKind)
and at least one child. Image and Link hold their alt or label as fragments;
their source and target stay unresolved strings.
Markdown AST to Dialogue AST mapping
| Markdown AST | Dialogue AST | Notes |
|---|---|---|
Heading |
SceneHeading |
flat token with level and title fragments (D4) |
Paragraph |
one or more Lines |
split at hard breaks (D3) |
| leading text of a line | a Speaker |
try-parse the whole prefix (D6) |
TextInline |
Text, Tag, JumpIndicator |
tokenized (D7) |
EmphasisInline |
StyledText |
children recursed |
ImageInline / LinkInline |
Image / Link |
alt and label under the label policy (D7) |
CodeSpanInline |
a GameCall, Condition, or weight |
by position and shape |
LineBreak soft / hard |
LineBreak / — |
a hard break ends the line (D3) |
ListBlock / ListItem |
Choices / Choice, or RandomChoices / RandomOption |
weighted items make a random choice |
QuoteBlock with if markers |
ControlBlock / Branch |
Key design decisions
D1 — Our own Dialogue AST
Downstream stages depend on dialogue concepts, never on Markdown or Markdig. The
AST carries unresolved references: a Link keeps its raw target, a Speaker its
raw name, id, and tags.
D2 — A lexer for the dialogue dialect, not a composer
Recognition is local: decidable from one node and its text. The transpiler emits
JumpIndicator and Link separately rather than a Jump, so a bare link stays an
ordinary inline link and composition lives in one place (desugar).
D3 — Hard breaks split lines; soft breaks stay
A hard break separates speeches, so it is consumed as a line boundary. A soft break
inside one speech is kept as a LineBreak, a display-wrap hint. An empty group
between two hard breaks is dropped rather than emitting an empty line.
D4 — Headings are flat tokens
A SceneHeading sits in the body beside the blocks after it. Nesting by level is a
document-wide computation and belongs to the semantic analyzer, which also handles
irregular outlines (an H1 after an H2, a skipped level).
D5 — Choices keep their order and their speakers
A choice body runs the same Build, so - Alice: Hi is an attributed option. The
list's IsOrdered is kept: an ordered list fixes display order.
D6 — Speaker prefix: declaration versus reference, by shape
| Prefix | Node | Meaning |
|---|---|---|
name + id and/or tags (Alice @A #x:) |
SpeakerDeclaration |
binds metadata |
bare name (Alice:) |
SpeakerNameReference |
points at a speaker by name |
bare id (@A:) |
SpeakerIdReference |
points at a speaker by id |
id + tags (@A #x:) |
PartialSpeakerDeclaration |
adds tags to the speaker with that id |
The prefix is recognized only if the leading text parses fully as a prefix
ending in :, so Alice: The time is 3:00 has a speaker and The time is 3:00
does not. Tags with neither a name nor an id (#tag:) report DLG1101. A styled
name (*Alice*:) is not a prefix and reports
DLG1107.
D7 — One inline walk, a policy per context
Speech, emphasis children, labels, and alt text are all MarkdownInline
sequences, so one InlineBuilder walks them under an IInlinePolicy that decides
what the context admits (Supports) and whether => is a jump (SupportsJumps):
| Policy | Used for | Admits | Unsupported element |
|---|---|---|---|
AllowAllInlinePolicy |
speech | everything; => is a jump |
— |
TitleInlinePolicy |
heading titles | everything; => is text |
— |
LiteralInlinePolicy |
link labels, alt text | text and styling | restored to its plain-text form |
RejectingInlinePolicy |
an alternative label policy, not in the default composition | text and styling | dropped, reporting DLG1103 |
InlineLeafTokenizer builds Repeated(Or(text, tag, jump)).ConsumeAll() from the
allowed leaves, dropping jump where jumps are off. It honors the
TextInline escape flag, so \#word and \=> never become a Tag or a
JumpIndicator (see Symbol Escape).
D8 — Parsers produce data; builders produce nodes and diagnostics
Every parser is one non-throwing contract that consumes a prefix:
interface IParser<T> { ParseResult<T> Consume(ParseInput input); }
ParseInput(Text, Position)anchors positions to the source, so matches report absolute ranges.ParseResult<T>is aParseMatch<T>(Value, TextRange)or aParseError(Detail).TextRangemay be empty (an absent optional part); it becomes aSourceSpanonly when a node is built.Spanned<T>carries a sub-part's span, so each tag in a prefix gets its own.
Superpower does the character-level leaves, wrapped once by SuperpowerParser;
structure composes with LINQ Select / SelectMany, Optional, and Repeated.
Parsers return span-free records (TagData, SpeakerPrefixData, QueryData,
DefaultCommandData, CustomCommandData). The builders classify the data, stamp
the span only they know (a code span's span includes backticks the parser never
sees), and report any diagnostic. Whether the whole input must be consumed is the
builder's policy, not a property of the parser.
D9 — Report and recover
A malformed surface reports a diagnostic and recovers, so the stage always returns
a ScriptDocument. The transpiler reports DLG1101–DLG1105 and DLG1107–DLG1112;
each code's recovery is listed in
Diagnostics and Validation.
Error and boundary cases
| Case | Behavior |
|---|---|
| Text with no valid prefix, or a colon inside speech | No speaker; desugar fills the default. |
| Code span that is not a game call | DLG1102; the text is kept literally. |
Tags with no name or id to attach to (#tag: Hi) |
DLG1101; the tags are dropped. |
=> not followed by a link |
Emitted as JumpIndicator; desugar degrades it and reports DLG1113. |
Whitespace between => and its link |
Kept as Text; folded into the Jump by desugar. |
Literal #word |
A Tag, unless escaped (\#word). |
Empty emphasis (****) |
Markdig leaves it as text, so StyledText is never empty. |
| Content before the first heading | Part of the document body. |
| Deeply nested choices | Represented faithfully; DLG3002 advises past level 3. |
| A game call or link inside a label | Restored to text by the default policy. |
| A node the front end ignored | Never reaches the transpiler. |
Testability
- Parsers are pure
text → datafunctions, tested directly, including the failing inputs. - Builders are tested on data and assert nodes, spans, and diagnostics.
BlockBuilderandLineBuilderare tested on small Markdown ASTs built through the front end, asserting withDialogueAstAssert.- Inputs are multi-line raw string literals, and tests run in parallel.