Document model
Every ScriptPub script, in whatever syntax it is stored, parses into one canonical model. This is the de-facto data structure every adapter reads and writes, and everything else — the editor, the reader, diff/merge, printing — operates on it.
ParsedDocument = {
config: FormatConfig, // the format — see /format/config
content: ContentBlock[], // one flat, ordered stream of blocks
sections?: SectionSpan[], // resolved section membership (see below)
}
contentis one flat stream of blocks, in document order. A block never contains a section — sections are points in the stream, not containers.sectionscarries the resolved section membership as spans. Absent or empty means an unsectioned document (the whole stream).
ContentBlock
A block is one paragraph-level unit of the stream. Which of runs / rows it
carries follows the block kind’s declared shape; a void
block carries neither.
| Field | Type | Description |
|---|---|---|
kind* |
string |
The block kind — a config.blocks[].id, or the default Text kind. |
runs |
Run[] |
Inline content — text shape. |
rows |
Cell[][] |
Rows of cells of blocks — grid shape (tables, side-by-side, wrappers). |
attrs |
BlockAttrs |
Fixed-vocabulary attributes. |
The three shapes
A block type declares one shape. There are exactly three:
| Shape | Content field | Covers |
|---|---|---|
text |
runs |
Paragraphs, headings, cues, dialogue — the default. |
void |
(none — attrs only) | Rules, page breaks, media — images, audio, video (src and the other media attrs). |
grid |
rows |
Tables, side-by-side / dual dialogue, card grids, <figure> / <details> / <blockquote>. |
A grid is the one construct that covers tables, side-by-side dialogue, card
grids, and single-cell wrappers alike. Its presentation — table vs
css-grid vs masonry vs flow — is styling (display,
grid-template-columns, …), not a distinct shape: a table is a grid; side-by-side
dialogue is a one-row grid; a <figure> is a single-cell grid.
A grid nests exactly one level deep: a grid’s cells hold leaf blocks — text /
void (plus lists via attrs) — never another grid. This bounded nesting is
deliberate — it keeps the model flat enough to diff, paginate, and reason about,
while still expressing side-by-side dialogue and grids.
Lists are flat blocks, not a grid
A list item is a plain text block carrying list metadata — not a nested
structure:
{ "kind": "paragraph", "runs": [ … ], "attrs": { "list": { "type": "ordered", "depth": 2 } } }
type is "ordered" or "unordered"; depth (default 1) expresses
Markdown-style nesting as data. Adapters map this naturally — Markdown - / 1.
with indentation, HTML <ul>/<ol>/<li> synthesized on save and dissolved on
parse (the model never stores a <ul> as a block), plain-text indentation.
Ordered lists number automatically, dovetailing with
behavior.numbered.
Run
A run is a stretch of text — or one atomic inline object — under one mark
stack. Exactly one of text / object is present.
| Field | Type | Description |
|---|---|---|
text |
string |
The text (a text run). May contain literal newlines; each syntax defines their encoding (HTML uses <br /> by default). |
object |
InlineObject |
An atomic inline object (an object run) — an inline image. One caret position; never split. |
marks* |
MarkUse[] |
The mark stack, outermost first. |
A MarkUse is either a bare mark id ("em") or, when the mark carries
attributes, an object { id, attrs } — a link’s href, ruby’s rt, a revision
rev. Marks nest freely, like HTML, and apply to object runs exactly as to
text (a linked inline image is an object run under an href mark). See
Marks & links and, for the object run’s editing and plain-text
projections, Media.
{
"kind": "line",
"runs": [
{ "text": "And every light that says ", "marks": [] },
{ "text": "come home", "marks": ["em"] },
{ "text": " says ", "marks": [] },
{ "text": "stay away", "marks": ["strong"] },
{ "text": " the same.", "marks": [] }
]
}
Cell
A grid cell is a list of leaf blocks, optionally spanning, marked as a header, or aligned:
Cell = {
blocks: ContentBlock[],
attrs?: {
colspan?: number,
rowspan?: number,
header?: boolean,
align?: "start" | "center" | "end",
},
}
A grid block’s rows is a Cell[][] — an array of rows, each an array of cells.
A cell’s blocks are leaf blocks (text / void, plus lists via attrs) — a grid
never nests inside a grid.
SectionSpan
Sections are modeled as points in the stream (see
Sections & divisions), but the resolved membership is stored
as half-open spans over content:
SectionSpan = { section: string, start: number, end: number } // [start, end)
sectionis aconfig.sections[].id;startis the first block index (inclusive) andendis one-past-the-last (exclusive).- Spans are contiguous mid-document — no gaps. Any block index covered by no span is an edge block (before the first point, or after a trailing wrapper close with no further point).
endis what lets the model capture a trailing edge region — blocks after a section’s wrapper closes with nothing following — that bare open points cannot.
The grouped, section-by-section view a reader uses is derived from these spans, never stored as nesting. Blocks covered by no span form groups with no section.