Skip to content

0.97 → 0.98 — the block vocabulary opens, and txt retires

Stored blobs are unaffected: every 0.97 document still loads byte-identically, and 0.98 writes the same bytes for the same content. Every break in this release is a compile-time or call-site one.

Break Surface Action
LineKind / Container gain Unknown Rust Add an arm to exhaustive matches
Invariant / ApplyError gain variants Rust (quillmark-content) Add an arm to exhaustive matches
Line / LineKind / Container drop Eq Rust Affects a hash-set element or map key only
ContentLine / ContentContainer gain an open arm TypeScript Use the new narrowing guards
OutputFormat::Txt removed Rust, Python, TS, CLI Pick pdf / svg / png
format_not_supported unified Diagnostic codes Route on backend::format_not_supported
ReadValue / Quill accessors removed Rust Match the variant; walk quill.files()
Python regions carry DocPath Python Read main.body / cards.<kind>[<i>].<field>

The block vocabulary opens

A line's kind and a container's name join the mark type and island type as open sets: a value this build does not recognize round-trips opaque and renders as its nearest safe neighbor instead of failing the whole document (#1054). Adding a block construct — a callout, a footnote, a task item, an indent — is no longer a document schema-version event.

Why

The mark axis was already open: MarkKind::Unknown and an unknown island type round-trip opaque. LineKind and Container returned a shape error instead, so one future construct cost a reader the whole document. Both axes now open on the same terms, and DOCUMENT_STORAGE.md § Open vocabularies states the rule.

The trade, stated: an older reader renders a future construct as a plain paragraph rather than refusing to open the document. No data is lost — the tag and its attrs still round-trip for a reader that understands them.

Rust: two new variants, and Eq drops

LineKind::Unknown  { tag: String, attrs: serde_json::Value }  // projects as `Para`
Container::Unknown { tag: String, attrs: serde_json::Value }  // projects transparently
  • Exhaustive matches on LineKind or Container need an Unknown arm. Treat an unknown line as a paragraph and an unknown container as absent — that is what both emitters do.
  • Line, LineKind, and Container no longer derive Eq (the opaque attrs is a serde_json::Value, which is PartialEq only), matching MarkKind. ==, assert_eq!, and Vec::contains are unaffected; a HashSet<Line> or a Line-keyed map is not.
  • Invariant gains ReservedUnknownLineKind / ReservedUnknownContainer — an unknown may not reuse a built-in name (heading, quote, …), which would serialize as the built-in and parse back as one, dropping its attrs. This is the ReservedUnknownTag rule, one axis over.

TypeScript: the open arm blocks narrowing

ContentLine and ContentContainer each carry a residual open arm, exactly as ContentIsland and ContentMark have since 0.97:

| { kind: string; attrs: unknown }         // ContentLine
| { container: string; attrs: unknown }    // ContentContainer

A bare discriminant check no longer narrows the payload — a string can equal 'heading', so TS keeps the open arm live and level stays unreachable. Three guards join isTableIsland / isImageIsland / isLinkMark / isAnchorMark in @quillmark/wasm/runtime:

import { isHeadingLine, isCodeLine, isListItemContainer } from '@quillmark/wasm/runtime';

// 0.97
if (line.kind === 'heading') indent(line.level);

// 0.98
if (isHeadingLine(line)) indent(line.level);

The payload-free arms (para, island, rule, quote) narrow to nothing, so a bare line.kind === 'rule' check still reads fine — only the arms carrying level / lang / a list item's shape need a guard.

Minting your own construct

An unknown tag must carry its payload under attrs, not in named sibling keys. A sibling key a reader does not know is dropped when that reader re-encodes; attrs survives whole.

OutputFormat::Txt retires

No backend listed Txt in SUPPORTED_FORMATS, so every path that reached it failed at render time (#1058).

Surface 0.97 0.98
Rust OutputFormat::{Pdf, Svg, Txt, Png}, ALL: [_; 4] {Pdf, Svg, Png}, ALL: [_; 3]
Python OutputFormat.TXT removed — AttributeError
TypeScript 'pdf' \| 'svg' \| 'txt' \| 'png' 'pdf' \| 'svg' \| 'png'
CLI --format txt fails at render fails argument parsing

There is no replacement format. OutputFormat::from_str("txt") returns ParseOutputFormatError, whose message lists the three live formats, and exhaustive Rust matches over OutputFormat lose an arm.

One backend::format_not_supported

The Typst backend emitted typst::format_not_supported and pdfform emitted pdfform::format_not_supported for the same condition — an output format the backend does not produce. Both now emit backend::format_not_supported, alongside the sibling backend::apply_unsupported (#1057).

// 0.97
if (d.code === 'typst::format_not_supported' || d.code === 'pdfform::format_not_supported')

// 0.98
if (d.code === 'backend::format_not_supported')

A backend of your own should mint the same code, so a host routes on one string whichever backend ran.

Removed Rust surface

Four accessors are removed, each with a live equivalent (#1066, #1064):

Removed Use instead
ReadValue::as_text() match ReadValue::Markdown(s) / ReadValue::Plaintext(s)
ReadValue::as_value() match ReadValue::Value(v)
Quill::list_files(dir) quill.files().list_files(dir)
Quill::list_subdirectories(dir) quill.list_directories(dir) (full paths) or quill.files().list_subdirectories(dir) (names)

QuillValue's is_null / as_str / as_bool / as_i64 / as_u64 / as_f64 / as_array / as_object are also gone, but they shadowed identical methods on the Deref target — QuillValue: Deref<Target = serde_json::Value> resolves every one of those calls unchanged. No action.

Python: regions carry DocPath

The RenderedRegion contract puts the plate→DocPath translation at the binding boundary. WASM did it; Python did not, so a Python caller got an address no document API accepts (#1063). Both bindings now call the shared regions_to_doc_path.

0.97 — plate-space 0.98 — DocPath
$body main.body
title main.title
$cards.recipient.0.name cards.recipient[<abs>].name
$cards.recipient.0.$body cards.recipient[<abs>].body

The card index changes meaning as well as spelling: the plate form carries a per-kind ordinal (the nth recipient), the DocPath form an absolute card index (the nth card in the document). The two coincide only when one kind is present. Code that string-matched the plate form, or worked around a document API rejecting region["field"], reads the DocPath form directly instead. WASM is unchanged.

Stricter decode, and one markdown fix

Content::from_canonical_json rejects two inputs 0.97 accepted (#1051). Neither is producible by a Quillmark writer; both are reachable from a hand-built or hostile blob.

  • Nesting past MAX_NESTING_DEPTH (100) fails with Invariant::NestingTooDeep. Export recurses one frame per container, so a deep enough path decoded clean and then aborted the process on to_markdown.
  • A wire position past usize fails instead of truncating. On wasm32, as usize landed 2^32 + 5 at position 5 — a mark at the wrong place in a document that then validated clean.

A mark spanning an island slot no longer swallows it, and a LineKind that disagrees with its segment is caught by Invariant::LineKindMismatch, carrying a LineKindMismatch reason (IslandNotOneSlot / RuleNotEmpty / CodeHasSlot). A direct quillmark-content dependent matching exhaustively on Invariant or ApplyError needs arms for these.

Markdown import keeps a literal * that abuts strong or emphasis. Under CommonMark's rule of three, a***a** is a, a literal *, then a strong a; the deleted fixup dropped the typed star and imported aa (#1053). ***bold italic*** is unaffected — it nests natively either way. The same removal fixes a backslash escape or entity in one of those spans re-entering the content as literal source bytes.