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: or data: 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 WebP data: 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 every href and src.

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.md entry 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.md now 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. format put 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_comments off, which is the default, or any other markup -- as one bare text node in the block position. Upstream never produces that shape: markdown-it runs with html: false and has no HTML block rule, so the same lines are a paragraph there. The document schema does not allow a text child, 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_comments on is still a comment node, and inline HTML is unchanged. No conformance case moves.

Added

  • The changelog is a page of the site. site/config.yaml mounts 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_spanned reports where each attribute of a tag body was written, and Node::annotation_locations carries the same information in document coordinates, parallel to Node::annotations. all covers the item as written; value covers 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_tag is now parse_tag_spanned with the spans dropped, so the two cannot disagree about what parses. The spans travel beside Attribute rather 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 #id and .class shortcuts report no value range, 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. See specs/features/attribute-spans.md.

Changed

  • Breaking: Node gains a public field. annotation_locations joins the struct, and Node is 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, and Node::new fills 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's Debug output carries the new field. The hand-written emitter is pinned against the derive, so every {:?} and {:#?} of a node now includes annotation_locations, whether or not the feature is used. Node Debug output 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 onto Schema, 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-method Declaration trait, 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 type written 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 named accent-proust, with one command per stage. fmt reprints Markdoc source in canonical form, from files or stdin; --check prints a unified diff of what would change and exits 1 if anything would, --write rewrites in place. fmt formats to a fixed point: format(parse(s)) settles in one pass on everything but one shape the library documents, so fmt reformats its own output until it stops changing, and write-then-check is clean by construction. CRLF files are named by --check and rewritten with LF.

    validate reports what a schema says is wrong, one line per error as path:line:column: level[id]: message, or with --format json one object per input in the shape the WebAssembly bindings return; it exits 1 on an error at level error or critical and prints the rest. render prints HTML; transform and parse print 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 that count=3 is the number and --var and --config can 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 now render::Html behind a trait, and render_with and render_all_with take any other implementation: a different escaping policy, a different void-element list, or a format that is not HTML. render and render_all are unchanged and produce the same bytes.

    The trait is open, close and text, plus a provided number whose default spells a value as ECMAScript does; open answers with Children, a #[non_exhaustive] enum of Render or Skip. 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_into and attribute_value are public so an implementation can keep the parts of upstream's behaviour it wants. The escaper does not replace ', which was safe while only Html used it and is now a documented condition: text and double-quoted attributes only.

Changed

  • Breaking: Config holds a SchemaSource instead of two maps. nodes, tags, nodes_mut() and tags_mut() are gone. config.schemas is an Arc<dyn SchemaSource + Send + Sync>, MapSchemaSource is the implementation that holds what the two maps held, and builtins::config_with builds a config around one -- the built-in functions added, the built-in schemas built once. builtins::config() is unchanged, and Config::with_schemas swaps the source on a config already held. Registering a tag on top of the built-ins was

    let 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::find returns a borrow, which rules out loading a schema on first request by design: a host populates its source before handing it over. Config's Debug still prints the registered names, through the trait's provided tag_names and node_types, and prints None for a source that cannot enumerate. SchemaKey is 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.sh builds it and .github/workflows/pages.yml publishes 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.toml knows it exists and scripts/check-standalone.sh is unaffected. The playground's engine is compiled from crates/accent-proust-wasm on 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-wasm exposes validate, renderHtml, transform and format to a browser or any other JavaScript host. The renderable tree it returns carries Markdoc's $$mdtype marker, so a renderer written against @markdoc/markdoc maps 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 serde feature 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, renderHtml and transform are methods on it. Without this the bindings knew only Markdoc's own tags, so a document written for a host reported tag-undefined for every component it used.

    Hooks, custom attribute types, RegExp in matches and 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, offset and byteOffset, 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 a Tokenizer. Ported from upstream Markdoc v0.5.9 (revision afee1a4).
  • Validate. Schemas, attribute types, and the validator. Upstream's error ids are reproduced exactly, because external tooling binds to them.
  • Transform and render. transform builds a renderable tree; render emits HTML.
  • Format. Canonical Markdoc source from a tree. format(parse(s)) is idempotent and parse(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: Tokenizer for Markdown segmentation, SchemaSource for where a schema comes from, and TagRenderer for escaping and markup.
  • The pulldown-cmark-tokenizer feature, on by default, supplying a Tokenizer over 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, PartialEq and Debug by hand, so that no traversal recurses per level and overflows the stack. No unsafe anywhere.
  • 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::Err is 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.