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:
- Match with CSS semantics — the element is tested against each kind’s
selector as
Element.matcheswould. 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 toElement.matches. - Highest specificity wins. Among all matching kinds, the highest
selector specificity — counted as (classes + attributes, type), no ids in
kind selectors — wins:
p.parentheticalbeats a kind claiming barepregardless of definition order. Ties resolve byconfig.blocks/config.marksdefinition order. - 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
punless 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 tospan. - 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"] } }
}
matchis a disjunction of rules; the kind matches if ANY rule holds. A kind with nomatchin a syntax is never produced by inference there.writeis 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. |