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
LineKindorContainerneed anUnknownarm. Treat an unknown line as a paragraph and an unknown container as absent — that is what both emitters do. Line,LineKind, andContainerno longer deriveEq(the opaqueattrsis aserde_json::Value, which isPartialEqonly), matchingMarkKind.==,assert_eq!, andVec::containsare unaffected; aHashSet<Line>or aLine-keyed map is not.InvariantgainsReservedUnknownLineKind/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 theReservedUnknownTagrule, 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 withInvariant::NestingTooDeep. Export recurses one frame per container, so a deep enough path decoded clean and then aborted the process onto_markdown. - A wire position past
usizefails instead of truncating. On wasm32,as usizelanded2^32 + 5at position5— 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.