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)
}
  • content is one flat stream of blocks, in document order. A block never contains a section — sections are points in the stream, not containers.
  • sections carries 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)
  • section is a config.sections[].id; start is the first block index (inclusive) and end is 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).
  • end is 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.