Kind inference

Kind recognition has two engines.

Selector inference is how canonical SPUB HTML works: a block or mark kind declares one CSS selector, and an element is resolved by matching it with CSS semantics. This is the file’s own mechanism — this page’s first half.

Text inference is the recognition grammar for content-derived foreign syntaxes (Markdown, plain text, Fountain, FDX). It survives only as the per-kind import/export mappings under BlockDef.syntax, keyed by foreign syntax id, consumed by the app’s converters — this page’s second half, kept brief; the full foreign-format story is app documentation.

Selector inference

Every non-default block and every mark declares a selector. Resolution:

  1. Match with CSS semantics — the element is tested against each kind’s selector as Element.matches would. The engine implements the compound subset: type selector, .class (all class tokens, not just the first), [attr], [attr="v"], *, compounds of those, and comma-separated alternative selectors. The spec permits anything modern CSS can express; engines may support more, and an engine with a live DOM may simply delegate to Element.matches.
  2. Highest specificity wins. Among all matching kinds, the highest selector specificity — counted as (classes + attributes, type), no ids in kind selectors — wins: p.parenthetical beats a kind claiming bare p regardless of definition order. Ties resolve by config.blocks / config.marks definition order.
  3. The default block is the fallback when nothing matches (bare text is always the default kind — it has no element to match). The default is the built-in p unless the work declares its own. An invalid selector never matches; validation reports it separately.

Built-in kinds claim their bare tags (h2, blockquote, em, …). Declared kinds precede them in the merged order, so a classed selector outranks them by specificity and a declared bare-tag alternative wins the tie.

The write spelling

Serialization derives from the same selector: the first comma-alternative’s subject compound supplies the tag plus class list — "h3.scene" writes <h3 class="scene">, "em" writes <em>. Rules:

  • The first alternative must be a plain tag(.class)* compound; attribute tests there are a validation error (not writable).
  • A first alternative with no tag uses the shape default: p (text), hr (void), div (grid); a mark defaults to span.
  • The selector’s classes are the kind’s identity: consumed on parse, always emitted on write — so the document sheet’s identical selectors match in a bare browser. Additional authored classes ride attrs.class.
  • The default Paragraph kind writes an unclassed <p>.

One string is therefore recognition rule, write spelling, and stylesheet selector at once — the same fact is never stated twice.

Foreign-syntax spellings

A kind’s spelling in ONE foreign syntax — how the app’s converters recognize it on import and write it on export — lives under BlockDef.syntax, keyed by syntax id (md, fountain, fdx, txt, …):

"syntax": {
  "fountain": { "match": [{ "prefix": "INT." }, { "prefix": "EXT." }], "write": { "transform": ["upper"] } }
}
  • match is a disjunction of rules; the kind matches if ANY rule holds. A kind with no match in a syntax is never produced by inference there.
  • write is the one canonical spelling the exporter emits — a structural descriptor applied to the block’s existing runs, never a template that replaces the text.

Match rules

A rule is a conjunction of predicates — all present keys must hold:

Predicate Applies to Meaning
construct the block The syntax’s base construct — an md construct name ("heading", "paragraph", "blockquote", "code", "rule", "image", "list", "table", "footnote"), an FDX paragraph Type, a fountain element.
level the block md heading level (with construct: "heading").
regex the block Regex source, tested against the block’s plain text.
prefix / suffix the block Literal affix of the text ("INT.", "TO:").
caps the block All-caps: ≥1 letter and no lowercase letter.
indent the block Leading-whitespace count (tab = 1). A number (exact) or { min?, max? }.
blankBefore / blankAfter the block Whether a blank line sits before / after the block.
prev the previous block Its resolved kind — a block id, or any of a set.
next the next block Its raw text only — a TextRule, never its resolved kind (no circularity). Lookahead distance is 1.

A TextRule (used only by next) is the text-only subset: regex / prefix / suffix / caps / indent / empty. Text predicates test a block’s plain-text projection, in which an object run reads as empty — a paragraph opening with a decorative image still matches its prefix/caps rules.

Precedence in text inference is deliberately simple: kinds in definition order, rules in listed order, first match wins, one top-to-bottom pass (so prev sees already-resolved kinds). The default Paragraph block is the implicit final fallback. The prev-resolved / next-raw asymmetry is what prevents circular resolution — a Fountain character cue is an all-caps line followed by a non-empty line; its dialogue is any line whose resolved previous block was a cue.

The write form

write fields, and which converters read them:

Field Used by Meaning
construct, level md, fountain, fdx Construct / element / Type name; md heading level.
transform txt, fountain, md ("upper" | "lower" | "capitalize")[] case transforms.
indent txt, fountain Leading indent to ensure.
prefix, suffix txt, fountain, md Fixed affix ensured present — idempotently (not doubled if the text already has it). A scene heading typed INT. KITCHEN under write.prefix: "INT. " is never doubled into INT. INT. KITCHEN.