Changelog
Notable changes to accent-proust. The format follows
Keep a Changelog, and this project
follows Semantic Versioning.
Unreleased
0.12.4 - 2026-11-01
Security
- A link or image to a
javascript:,vbscript:,file:ordata:URL is text (GHSA-cchw-px5q-45w5). Every earlier release rendered[a](javascript:alert(1))as a working link, so a host rendering untrusted documents served script to anyone who clicked it. The parser now refuses the destinations upstream's markdown-it refuses (validateLink), allowing only a GIF, PNG, JPEG or WebPdata:image, and writes the link out as text, as upstream does. Case, entities, whitespace and control characters do not hide the scheme. A property test checks everyhrefandsrc.
Changed
- A block tag inside a list item or a blockquote belongs to it (#73). The
segmenter masks such a tag as
<?...?>, which pulldown-cmark places as an HTML block inside its container, and the parser turns it back into the tag. "Advanced table with conditional inside cell" now passes: conformance is 96 green, 9 annotated.DIVERGENCES.mdentry 13 now covers only what is left: a tag left open closes with its container, and its later lines must continue it.
Fixed
- Masked text no longer reaches the tree (#73). A tag spanning lines was
never put back, so its filler (
{%xxxx) became text. The same happened in a code block inside a container, a fence's info string, and an inline link's destination or title. A property test now checks this over generated documents. - An indented HTML block no longer adds a stray text node before itself.
0.12.3 - 2026-10-30
Changed
- Current with upstream Markdoc 0.5.11 (revision
9440a1d, was 0.5.9). The corpus is unchanged and the conformance count does not move.DIVERGENCES.mdnow measures against 0.5.11: entry 16 drops the blockquote defect upstream fixed, and entry 13 covers tags inside blockquotes.
Fixed
- A blockquote inside a list item reprints as written.
formatput the item's indent after the>as well as before it. A soft break gained spaces, a list's second item came back nested, and a fence never settled. The blockquote arm is now upstream 0.5.11's, and an empty blockquote keeps its>instead of printing nothing.
0.12.2 - 2026-10-06
Fixed
- An HTML block stays where it was written. The parser flushed a pending
raw-HTML block only at the next markdown event, and a tag delimiter is not
one. A block that ended right before a tag attached to whichever node was
open next: the tag that follows it, the parent after a closing tag, the
branch after
{% else /%}, the next sibling tag. A host that cuts a tag's body from its children got a body that started before the tag; Accent's expander recursed on it until the stack overflowed. The block is now flushed before every tag. The conformance suite passes against the existing baseline.
0.12.1 - 2026-09-19
Fixed
-
An HTML block is a paragraph of literal text, as upstream has it. The parser attached a block of raw HTML -- a comment with
allow_commentsoff, which is the default, or any other markup -- as one baretextnode in the block position. Upstream never produces that shape: markdown-it runs withhtml: falseand has no HTML block rule, so the same lines are a paragraph there. Thedocumentschema does not allow atextchild, so every document with an HTML comment and a tag failed validation with "Can't nest 'text' in 'document'", reported at line 1. A documentation site that keeps its source notes in comments saw hundreds of these.The block is now one paragraph per run of non-blank lines, each line a trimmed text node, joined by soft breaks. A comment with
allow_commentson is still acommentnode, and inline HTML is unchanged. No conformance case moves.
Added
- The changelog is a page of the site.
site/config.yamlmounts this file at/changelog, so the page is the file rather than a copy of it, and the header, the footer and the documentation sidebar link to it; the footer used to link to the file on GitHub. The Pages workflow now also runs when this file changes. The site only: no crate behaviour moves with it.
0.12.0 - 2026-09-16
Added
-
A byte span for every attribute.
grammar::parse_tag_spannedreports where each attribute of a tag body was written, andNode::annotation_locationscarries the same information in document coordinates, parallel toNode::annotations.allcovers the item as written;valuecovers the value alone, which is the range to replace to change one attribute and leave every other byte of the document -- the author's spacing and the tag body included -- exactly as it was.parse_tagis nowparse_tag_spannedwith the spans dropped, so the two cannot disagree about what parses. The spans travel besideAttributerather than inside it: that type mirrors upstream's{type, name, value}, the command-line host serialises it in that shape, and its tests are a case-for-case port of upstream's.The
#idand.classshortcuts report novaluerange, because their value is implied by the syntax rather than written. A fence annotated through its info string reports no locations at all: the tokenizer hands over that string's text but not where it sits, and a guessed offset cannot be told from a real one. Nothing that parsed before parses differently, and no rendered byte changes. Seespecs/features/attribute-spans.md.
Changed
-
Breaking:
Nodegains a public field.annotation_locationsjoins the struct, andNodeis not#[non_exhaustive], so a host that builds a node by literal or destructures one exhaustively has to name the new field. Reading a node is unaffected, every existing function keeps its signature, andNode::newfills the field like any other.The struct stays open rather than gaining
#[non_exhaustive]in the same breath: hosts and tests are invited to construct nodes, and closing it would take that away permanently to save naming one field once.#[non_exhaustive]is on the crate's public enums, where it earns its keep by making a new upstream node type a non-breaking release. -
Node'sDebugoutput carries the new field. The hand-written emitter is pinned against the derive, so every{:?}and{:#?}of a node now includesannotation_locations, whether or not the feature is used. NodeDebugoutput is observable; a snapshot or log diff keyed on it moves once, here. -
The site fits a phone, and its footer names the build. The stylesheet and the base and docs templates carry the responsive work. The site only: no crate behaviour moves with it, and it is recorded here because the website has been in this file's scope since 0.10.0 shipped it.
0.11.0 - 2026-09-12
Added
-
crates/accent-proust-schema-config, the declarative vocabulary both hosts read. The keys a schema declaration may carry, the refusal of an unknown one with the path to it, and the mapping ontoSchema, written once. The WebAssembly host's walker was the only copy; it reads through the crate now, and so does the command-line host. Each host keeps its own reading through a seven-methodDeclarationtrait, so keys are refused before values are converted: a hook written where none is allowed is refused as a key, with the host's reason, not as an unconvertible function. Errors carry a path and a kind, and the host adds the reason that is its own.One input the WebAssembly host used to accept is refused now: a
typewritten as a list inside a list. Upstream's own type never nests, and the old walker's recursion there was as deep as the document chose. A schema object of any kind -- a class instance, a proxy -- is still read key by key, and a property whose getter throws is refused as unreadable rather than read as absent. -
A command-line host,
crates/accent-proust-cli. A binary namedaccent-proust, with one command per stage.fmtreprints Markdoc source in canonical form, from files or stdin;--checkprints a unified diff of what would change and exits 1 if anything would,--writerewrites in place.fmtformats to a fixed point:format(parse(s))settles in one pass on everything but one shape the library documents, sofmtreformats its own output until it stops changing, and write-then-check is clean by construction. CRLF files are named by--checkand rewritten with LF.validatereports what a schema says is wrong, one line per error aspath:line:column: level[id]: message, or with--format jsonone object per input in the shape the WebAssembly bindings return; it exits 1 on an error at levelerrororcriticaland prints the rest.renderprints HTML;transformandparseprint the renderable tree and the syntax tree as JSON, one value per input, one per line. All three read a configuration:--config, a YAML or JSON file in the shared vocabulary;--partials, a directory whose files{% partial %}finds by relative path -- the thing the browser cannot do; and--var NAME=VALUE, with the value read as YAML so thatcount=3is the number and--varand--configcan never disagree. Exit codes are 0, 1 for a document with a problem, and 2 for a usage, read or configuration error, kept apart so that CI can tell "the docs are wrong" from "the tool is misconfigured". -
One lint block for the workspace. The library, the WebAssembly host and the command-line host opt into
[workspace.lints]instead of carrying a copy each. Inheritance is opt-in per member, so sharing binds no future host that leaves the line out; what it does is keep three crates from drifting apart by accident. -
TagRenderer, the seam between the renderable tree and its markup. The HTML renderer is nowrender::Htmlbehind a trait, andrender_withandrender_all_withtake any other implementation: a different escaping policy, a different void-element list, or a format that is not HTML.renderandrender_allare unchanged and produce the same bytes.The trait is
open,closeandtext, plus a providednumberwhose default spells a value as ECMAScript does;openanswers withChildren, a#[non_exhaustive]enum ofRenderorSkip. It is deliberately not "here is a tag, here is a callback for its children". The renderer walks an explicit stack because nesting depth is the document's to choose, and a callback would put that depth back on the host's stack one frame per level. The walk stays in the crate and the host only writes bytes.escape_html_intoandattribute_valueare public so an implementation can keep the parts of upstream's behaviour it wants. The escaper does not replace', which was safe while onlyHtmlused it and is now a documented condition: text and double-quoted attributes only.
Changed
-
Breaking:
Configholds aSchemaSourceinstead of two maps.nodes,tags,nodes_mut()andtags_mut()are gone.config.schemasis anArc<dyn SchemaSource + Send + Sync>,MapSchemaSourceis the implementation that holds what the two maps held, andbuiltins::config_withbuilds a config around one -- the built-in functions added, the built-in schemas built once.builtins::config()is unchanged, andConfig::with_schemasswaps the source on a config already held. Registering a tag on top of the built-ins waslet mut config = builtins::config(); config.tags_mut().insert("callout".to_string(), schema);
and is now
let mut schemas = MapSchemaSource::builtin(); schemas.insert_tag("callout", schema); let config = builtins::config_with(Arc::new(schemas));
One mechanism rather than a source consulted ahead of the maps, so there is no precedence rule to document.
SchemaSource::findreturns a borrow, which rules out loading a schema on first request by design: a host populates its source before handing it over.Config'sDebugstill prints the registered names, through the trait's providedtag_namesandnode_types, and printsNonefor a source that cannot enumerate.SchemaKeyis exhaustive, the one public enum here that is: an implementation matches both ways a node can be looked up, and a third -- none is foreseen -- would fail to compile rather than silently miss.
0.10.0 - 2026-09-06
Added
-
A project website, with the documentation and a live playground.
site/is a landing page, five documentation pages -- Rust, JavaScript, the Markdoc language, the architecture, and the divergences -- and a playground that runs the WebAssembly engine in the reader's browser.scripts/build-site.shbuilds it and.github/workflows/pages.ymlpublishes it to GitHub Pages.It is built with Accent CMS, the sibling generator this crate ships alongside, and it is not a workspace member: nothing in
Cargo.tomlknows it exists andscripts/check-standalone.shis unaffected. The playground's engine is compiled fromcrates/accent-proust-wasmon every build rather than committed, so it can never be a stale copy of a release. -
A WebAssembly build, published to npm as
accent-proust.crates/accent-proust-wasmexposesvalidate,renderHtml,transformandformatto a browser or any other JavaScript host. The renderable tree it returns carries Markdoc's$$mdtypemarker, so a renderer written against@markdoc/markdocmaps tag names onto components unchanged. Variables, partials and host-defined schemas are not across the boundary yet.Nothing in the library changed: the binding is a workspace member, and the mapping to JavaScript objects lives there rather than behind a
serdefeature here. -
Host schema configuration for the WebAssembly bindings.
new Config({ tags, nodes, variables })builds a validator configuration from declared data and merges it over Markdoc's built-ins;validate,renderHtmlandtransformare methods on it. Without this the bindings knew only Markdoc's own tags, so a document written for a host reportedtag-undefinedfor every component it used.Hooks, custom attribute types,
RegExpinmatchesand host functions are code and do not cross, so the browser is never stricter than the server. They are refused by name with the path to them rather than dropped, because a schema that half arrives hides the half that is missing. -
Diagnostic positions in UTF-16 code units. A location edge from the WebAssembly bindings carries
line,character,offsetandbyteOffset, and every field but the last is counted the way JavaScript counts a string index. The engine measures UTF-8 bytes, an editor position is UTF-16, and the two agree until an author writes a character outside ASCII -- at which point an unconverted offset underlines the wrong character. Converting at the boundary means no host rediscovers this.
0.9.0 - 2026-09-04
First release. The engine is complete: source text parses to an AST, an AST validates against a schema, a validated AST transforms into a renderable tree and renders to HTML, and a tree prints back to canonical Markdoc source.
0.9.0 rather than 1.0.0 because the API has had no external users yet. The
conventions below are already promises; the shape of the types is not.
Added
- Parse. A segmenter over raw text -- block-level
{% %}lines, inline spans, fence interception -- feeding Markdown segments to aTokenizer. Ported from upstream Markdocv0.5.9(revisionafee1a4). - Validate. Schemas, attribute types, and the validator. Upstream's error ids are reproduced exactly, because external tooling binds to them.
- Transform and render.
transformbuilds a renderable tree;renderemits HTML. - Format. Canonical Markdoc source from a tree.
format(parse(s))is idempotent andparse(format(ast))returns the same tree, so a formatter can rewrite a file in place without losing anything. - Three seams for the host, because this crate does no I/O, reads no
configuration, and decides no HTML policy:
Tokenizerfor Markdown segmentation,SchemaSourcefor where a schema comes from, andTagRendererfor escaping and markup. - The
pulldown-cmark-tokenizerfeature, on by default, supplying aTokenizerover pulldown-cmark. Turning it off leaves the trait and every layer above it, so a host that already parses CommonMark does not compile a second parser. That configuration is built and tested by CI.
Conformance
95 of upstream's 105 corpus cases match, 10 exercise a declared divergence, and
none fail. The corpus is vendored under spec/ and is the test suite;
conformance-baseline.txt is a ratchet that fails on drift in either direction.
The 16 deliberate differences are recorded in DIVERGENCES.md, which is
normative rather than a changelog. The largest is that CommonMark edge
behaviour is not part of the contract: upstream builds on markdown-it and this
crate builds on pulldown-cmark.
Guarantees
- Panic-freedom, asserted by property tests over arbitrary input and over
values a caller assembles through the public API. Every public recursive type
writes out
Drop,Clone,PartialEqandDebugby hand, so that no traversal recurses per level and overflows the stack. Nounsafeanywhere. - Deterministic output. Attribute order is authored order, never hash order, so two runs over the same input produce identical bytes.
- Validation errors are data. Validating returns a
Vec, so an editor can show every problem at once.Result::Erris reserved for internal invariants. - Public enums are
#[non_exhaustive], so a new upstream node type is not a breaking release. - MSRV 1.96 for the library, normalised across the Accent crates, on Rust edition 2024.