Skip to content

0.133 → 0.134 — Content v2: every content value is the content model, offsets cross JavaScript in UTF-16, and apply is the one write; every card carries an id; Python's revise verbs return the whole receipt; a revise names the anchors it drops and reads the annotated read back; a cell's anchor is a comment anchor

Every content value is the content model

The break every consumer sees: a body or richtext value is a content-model value wherever it crosses, in storage, the Card wire, doc.read, getStored, reader.getContent, importMarkdown, normalize and the receipts. Blocks, containers, anchors, islands, table rows, columns and cells carry ids, and nothing derived is stored (CONTENT.md).

{
  "text": "Ship v2\nDraft spec",
  "blocks": [
    { "id": "b0", "kind": "para", "path": ["l0", "i0"] },
    { "id": "b1", "kind": "para", "path": ["l0", "i1"] }
  ],
  "containers": [
    { "id": "l0", "kind": "list", "attrs": { "ordered": false, "start": 1 } },
    { "id": "i0", "kind": "item" },
    { "id": "i1", "kind": "item", "attrs": { "checked": true } }
  ],
  "anchors": [{ "id": "c-91be", "start": 0, "end": 7 }]
}
0.133 0.134
lines[], each with a container path blocks[], one per \n-separated segment, each with a path of container ids; the records are containers[]
continues: true lines one block, its lines joined by U+2028 hard breaks
a code block's lang on every line once, on the block
list_item with ordinal and instance, quote with instance list (ordered, start), item (checked) and quote containers, told apart by id; numbers are computed
an anchor mark an entry in anchors[]
a table's cells as {text, marks} in props.header and props.rows props.columns and props.rows of ids, each cell a slot under the island's slots; aligns, widths and cell_attrs keyed by column or slot id
a footnote's note in props the island's note slot

A value spelling lines still reads wherever a decoder meets it, and migrates. A host that builds content by hand writes the new shape: importMarkdown(md).content is the quickest way to get one.

Offsets cross JavaScript in UTF-16 code units

The break no type checker reports: every offset in a content value or a change crossing the WASM boundary counts UTF-16 code units, JavaScript string indices, where 0.133 counted Unicode scalar values. A mark, anchor or splice past an astral character (an emoji, a CJK extension character) lands one unit further per astral character before it. A host that converted to scalar values before writing drops the conversion. Python counts code points, as before. An offset inside a surrogate pair refuses the change (edit::content_apply). The render seam counts UTF-16 code units too: a region's span, positionAt's and locate's pos, a typed revise's Revised.delta and the deltas reattach takes, so a caret maps between the preview and the content value with no conversion. Core's LiveSession::*_in, Engine::render_in, Revised::delta_in and Document::reattach_in take a host's units.

apply is the one write

Document gains one write primitive and two producers over it, on WASM and Python alike, and each answers a Receipt:

const base = doc.hash({ field: 'summary' })
const receipt = doc.apply({ field: 'summary' }, {
  steps: [{ splice: { at: 4, delete: '', insert: '!', marks: [{ type: 'strong' }] } }],
}, { base })
// { applied, hash, droppedAnchors, movedAnchors, warnings }
  • apply(addr, change, { base }) runs a change's steps, all or none.
  • A write names a composable card by its $id: { card: doc.cards[i].id } (Python card=doc.cards[i]["id"]). An index throws there, since a card moved or inserted since the read would take the write.
  • revise(addr, markdown, { base }) rewrites a value from markdown, keeping the anchors and islands whose qm-* tags annotated(addr) spelled and the markdown kept.
  • replace(addr, content, { base }) writes a whole value, carrying each anchor it does not name where a word-aligned diff keeps its text.
  • read(addr) reads a value, hash(addr) its hash, annotated(addr) its markdown with qm-* tags.
  • invert(change, before) and diff(before, after) are module functions; diff answers the Change.
  • splitBlock, joinBlocks, deleteRange, indent, outdent, wrap and inheritedMarks are module functions over a Content, each answering the Change an editor gesture applies, and mapPos(change, pos, assoc?) carries a position across a change.

Pass base. It is the hash the writer read. A value that has changed since rebases the write over the change, and the receipt says rebased; a write that touches what changed refuses with edit::rebase_conflict, and a base the document no longer reaches with edit::stale_base. A write's change counts offsets into the value its base names. Without a base, a write lands over whatever the value holds, reverting an edit made since the read.

A revise or replace whose change removes an x- island, which markdown cannot spell, refuses with edit::drops_ext_island; remove one with apply.

Break Surface Action
Content values are the content model in storage, the Card wire and every read every binding Read blocks and containers where you read lines; read anchors where you read anchor marks
Offsets in content values and changes count UTF-16 code units WASM Drop any conversion to scalar values; pass JavaScript string indices
Region spans, positionAt / locate positions and Revised.delta count UTF-16 code units WASM Drop any conversion between the preview's positions and the content value's
A save writes quillmark/document@0.134.0, which a 0.133 build refuses; currentStorageVersion returns it every binding Upgrade every reader before the first writer. An older row loads migrated, minting the same ids on every load
overwrite, reviseContent, applyChange and mapMarks are removed WASM Call replace for overwrite and reviseContent, and apply with a Change for applyChange; replace carries anchors where overwrite destroyed them
mapPos(delta, pos, assoc) takes a Change and answers a MapResult { pos, deleted, deletedBefore, deletedAfter, deletedAcross }, where it took a line delta and answered a number WASM Map through the receipt's applied, and read .pos
ContentLine, ContentLineKind, TableCell, ImageProps, FootnoteProps, MathProps, MarkOp, LineOp, IslandOp and ChangeBundle leave the .d.ts WASM Use ContentBlock, ContentBlockKind, TableProps, ContentIsland, Change, Step and Receipt
A content-lane write takes { base }; baseHash is read as its alias, and baseMarkdown is refused there WASM Pass base; a markdown merge stays on the writer's reviseBody / reviseField
Every content-lane write names a composable card by $id and throw on { card: <index> }; Addr.card takes a card's $id on every verb, an unknown one throwing edit::unknown_card_id WASM Pass { card: doc.cards[i].id }. The other verbs still take an index
reader.get_content returns quillmark_content::model::Content; the line form moves under quillmark_content::lines Rust Import lines::model where you read the line form; Card::body() is the model, Card::body_lines() the line form
ChangeBundle, LineOp, MarkOp, IslandOp and their decoders, Card::apply_body_change / apply_field_change, Normalized::apply_* and EditError::ContentApply are removed, and quillmark::session no longer re-exports them Rust Build a Change and call Card::apply(target, change, base); a refused change is EditError::Change, still edit::content_apply

Merge repair

Rust repair::repair(raw) makes valid a state two concurrent edits merged into, by fixed rules, and reports what it changed. It is total and deterministic, so two peers repairing one state write the same value. A live session projecting a CRDT calls it before storing. Nothing calls it for you.

Diffs are word-aligned

diff, revise and replace diff a rewritten line by word, where 0.133 diffed by character. A range over a rewritten word drops, named in droppedAnchors, rather than riding its scattered letters. The writer's reviseBody / reviseField / reviseDocument keep 0.133's diff and their Revised receipts.

Every composable card carries a $id

The break every markdown consumer sees: to_markdown writes a $id: line after each composable card's $kind, and the card's id is stored as an id payload item.

~~~
$kind: indorsement
$id: c3kq9f
from: ORG1/SYMBOL
~~~

A card that spells no $id is minted one where it is placed: by a parse, by a load of an older row, by insert_card. The mint is a fixed sequence, so equal markdown parses to equal ids, and a stored id is never rewritten. An author may write their own, 1 to 64 of [A-Za-z0-9_-] opening with a letter or digit; a parse replaces one outside that grammar, or one an earlier card holds, and warns parse::card_id_reminted. $id on the root block is a parse error, and $id never reaches the plate JSON.

What the id buys:

  • A whole-document revise pairs a card by the id it spells before any text match, so a rewritten or moved card keeps its anchors and $ext. A pasted copy that keeps the original's $id is an inserted card with a new id. Where the markdown spells any stored id, a stored card whose id it leaves out is removed, so a new card written in its place takes neither its id nor its $ext. A list field's items now align by text, so an item inserted ahead of an anchored one leaves its anchor in place.
  • A comment selector names its card. AnchorSelector and DocumentAnchor gain card_id (cardId in WASM), and reattach finds the card by it wherever it moved, orphaning the selector where the card is gone. A selector stored without one stays path-strict. Remapping a dropped selector's card index through DocumentRevised.from is no longer needed.

A durable per-card key carried in $ext can move to $id.

Break Surface Action
to_markdown emits $id: <id> after each composable card's $kind every binding A test comparing emitted markdown byte-for-byte adds the line, or reads the id from the card
$id on the root block is a parse error markdown Remove it; the root is main
insert_card refuses a card whose id is not a card id or is held by another card (edit::invalid_card_id, edit::duplicate_card_id) every binding Drop id from a card copied within one document, so it mints a new one
Card / CardInput gain id; AnchorSelector, DocumentAnchor and the Python dicts gain card_id every binding A DocumentAnchor { .. } literal adds the field; an exact-equality test on a selector or receipt adds it
PayloadItem gains Id Rust An exhaustive match adds an arm

Python's revise verbs return a dict

Python's break no type checker reports: writer.revise_body, writer.revise_field and writer.revise_document return a dict receipt, where they returned a list of Diagnostic. A loop over the result iterates the dict's keys, so for w in writer.revise_body(md) runs over "delta", "warnings" and "dropped_anchors" without failing, and writer.revise_field(name, md) == [] is False on every call. The warnings are the same Diagnostic objects under receipt["warnings"].

# 0.133
for w in writer.revise_body(markdown):
    print(w.code, w.path)

# 0.134
receipt = writer.revise_body(markdown)
for w in receipt["warnings"]:
    print(w.code, w.path)
Verb 0.133 returned 0.134 returns
revise_body(md, card=) list[Diagnostic] {"delta": {"ops": [...]}, "warnings": [Diagnostic, ...], "dropped_anchors": [{"id", "line"}, ...]}
revise_field(name, text, card=) list[Diagnostic] the same shape, its warnings at the field
revise_document(md) list[Diagnostic] {"dropped_anchors": [{"id", "path", "line"}, ...], "warnings": [Diagnostic, ...], "from": [int \| None, ...]}

dropped_anchors names each comment anchor the write dropped, and from the stored card each revised card revises, None for an inserted card. WASM's reviseDocument already returned both, and its reviseBody and reviseField gain droppedAnchors in this release.

A revise names the anchors it drops

Revised gains dropped_anchors: every anchor of the stored value, in its prose or a table cell or footnote note, whose id the revised value holds nowhere, each an AnchorRead { id, line }, a cell's line its text. Card::revise_body, revise_field, revise_body_content, revise_field_content and the typed writer's revise_body / revise_field fill it. Revised is #[non_exhaustive], so a pattern already carries ... WASM's Revised gains droppedAnchors: { id, line }[] on writer.reviseBody and writer.reviseField.

New, with nothing to migrate: the comment-anchor handle. Document::anchor_contexts / anchor_context(id) read each anchor's AnchorSelector { id, path, quote, prefix, suffix }, and Document::reattach(selectors, deltas) places a dropped anchor again where its quote is found once with its context agreeing, returning Reattached { reattached, orphaned }. WASM spells them anchorContexts, anchorContext and reattach(selectors, deltas?), Python anchor_contexts, anchor_context and reattach(selectors, deltas=None). The content crate's lines::anchor module holds the per-field rule: anchor_contexts, locate, Content::reattach and its constants. Delta::inserted_spans is public. The facade re-exports AnchorSelector, Reattached, AnchorRead and DocPath.

An export can name what it does not spell

New, with nothing to migrate. quillmark_content::markdown::to_markdown_checked (lines::export::to_markdown_checked over the line form) returns the markdown with a receipt: each mark, island or run of text the markdown reads back as something else, which core mints as export::lost_construct warnings. It reads the markdown back to check it, so call it where the receipt is wanted and to_markdown / exportMarkdown elsewhere.

A revise reads the annotated read back

to_markdown_annotated (WASM doc.toAnnotatedMarkdown()) is two-way. Every revise verb, rebase and a whole-document revise read its tags against the value they revise:

  • A <qm-anchor ref="ID"></qm-anchor> tag the writer kept pins its anchor's start where it stands, so a writer that moves a tag moves the anchor. One it dropped leaves the anchor to the diff, as every tag did in 0.133. One naming an anchor the value does not hold, or naming one a second time, drops with parse::dropped_construct, construct: "qm-anchor[ref]".
  • The read writes at="span" on a tag it moved to a code span's or link's start, and at="line" on one it moved to its line's end, where 0.133 wrote no attribute. A tag carrying at pins nothing.
  • An island whose markup does not read back as itself, such as a table holding a key Quillmark does not name, reads as <qm-island ref="ID"></qm-island>, where 0.133 wrote its lossy markup. A revise keeps the island's type and props as stored where the token stands; a token naming no stored island drops as qm-island[ref], and one in a cell, a note or an image's alt as qm-island.
  • A cold import (from_markdown, Document::parse, importMarkdown) drops every token, reported as qm-island, and still drops every anchor tag silently.
  • A Base::Read merge compares the sides as annotated reads, so a token survives the merge and a moved tag pins through it.
Break Surface Action
The annotated read writes a qm-island token for an island its markdown loses, which a cold import drops reported every binding Write the read back through a revise, never a cold import; a cold reader keeps reading to_markdown
An anchor tag the read moved carries at="span" or at="line" every binding A test matching the read byte for byte adds the attribute
A revise moves an anchor whose tag the writer moved, and reports qm-anchor[ref] / qm-island[ref] for a tag naming nothing stored every binding None; a writer that keeps the tags as read sees what 0.133 did
Under lines, diff_import imports through from_markdown_against; delta gains rebase_pinned and rebase_marks_pinned; import gains from_markdown_against, from_markdown_unresolved, Against, Pin and Dropped::is_held_tag; export gains island_reads_back; carrier::RESERVED holds island Rust None for a caller of the existing functions

A cell's anchor is a comment anchor

A table cell's and a footnote note's anchors are read, carried, reported and reattached as the prose's are. In 0.133 an anchor in a cell was outside every read, dropped unreported on any markdown write that spelled its cell, and a RemoveAnchor left it.

  • anchor_contexts, the annotated read's anchors and every dropped_anchors receipt list it, its context cut at the cell's edges and its line the cell's text. No tag spells it.
  • A revise keeps it where its island continues and the cell at its place keeps its text, and names it dropped otherwise.
  • An anchor step refuses an id the value or any slot holds, so a value holds each anchor id once.
  • reattach searches every cell with the prose, so a selector whose quote is found once in a cell lands there.
  • A stored value holding an id twice with a cell taking part loads with its first holder keeping it.
Break Surface Action
anchorContexts, toAnnotatedMarkdown().anchors and droppedAnchors include cell anchors every binding A consumer that keyed each entry to a prose position reads the cell's text from line
A stored value holding an anchor id twice loads with the second holder's anchor gone every binding A host minting unique ids sees none
The line form's IslandOp::Set / Insert can refuse with ApplyError::AnchorIdCollision; RemoveAnchor reaches cells Rust None for a host minting unique ids
lines::anchor gains Surface, AnchorSite, locate_site; the line form's Content gains anchor_sites and anchor_collisions Rust None

One card or field at a time

New, with nothing to migrate. doc.annotatedAt(addr) (Rust Card::body_annotated / field_annotated) is the annotated read of one body or richtext field, the markdown a revise at that address reads back and its baseMarkdown. It is WASM-only on the bindings.

A revise can state what its writer read

New, with nothing to migrate unless you match EditError or BoundParseError exhaustively. Every revise verb takes an optional base: a hash of the value the writer read refuses a stale write (edit::stale_base), and the markdown it read merges its edit onto the change since (edit::rebase_conflict where both changed a line). WASM passes { baseHash } or { baseMarkdown } as the last argument, read through writer.hash() / writer.hashAt(addr); Python passes base_hash= or base_markdown=, read through writer.hash() / writer.hash_at(); Rust calls the *_against verbs with a Base.

Break Surface Action
revise_body, revise_field and revise_document return a dict receipt, where they returned list[Diagnostic] Python Read receipt["warnings"], the same Diagnostic objects
Revised has a public dropped_anchors field Rust None for a pattern with ..; a Revised { .. } literal outside the crate was already refused
revise_import takes &Normalized, where it took &Content Rust Pass content.into_normalized(); a stored or imported content is already one
EditError gains Base(BaseError) and BoundParseError gains Base Rust An exhaustive match adds an arm; only the *_against revise verbs raise them
Dropped gains Bidi, reported as parse::dropped_construct with construct: "bidi" Rust, every binding An exhaustive match adds an arm; a consumer that treats any import warning as fatal now sees one where a bidi control dropped
A *, _ or ~~ mark over edge whitespace exports over its text without the whitespace, where it exported as no mark every binding None; the markdown keeps a mark it lost

Removed

Break Surface Action
The backend::update_unsupported code is gone; SessionHandle::update has no default every binding None: no built-in backend raised it
Document.revise is removed WASM quill.writer(doc).reviseBody(md) / reviseField(name, md), or doc.replace(addr, content) for a content built elsewhere
rebase is removed WASM doc.replace(addr, content) on the stored value, or the writer's reviseBody / reviseField
doc.loadStored(json) is removed WASM Document.fromStored(json)
doc.storeFields(addr, fields) is removed WASM doc.storeField(addr, value) per field, or the typed writer.setAll(fields)
Quill.seedMain() is removed WASM quill.seedDocument().main
Document.markdownRules() is removed WASM None; Python's Document.markdown_rules() stays
assignInstances is removed WASM Number each run's instance by its order among same-kind siblings, as the function did
CardReader and reader.card(i) are removed WASM Pass { card: i, field } to reader.get / getContent / getContentAt
CardWriter.index / kind, DocumentWriter.document, DocumentReader.document and LiveSession.backendId are removed WASM Keep the index and document you passed; read quill.backendId
BUNDLE_MEDIA_TYPE is removed Rust Spell application/vnd.quillmark.quill+zip where you serve a bundle
QuillConfig::is_snake_case_identifier is crate-private Rust None
quillmark_pdf::CHECK_GLYPH and FieldSpec::assert_finite_rect are crate-private Rust None; stamp checks the rect itself
quillmark_content::serial::line_kind_to_value is removed Rust Read a line's kind and attrs from the line objects to_canonical_value writes
serial::line_kind_from_value, container_from_value and mark_from_value are crate-private Rust Decode a whole value through lines::serial::from_canonical_value