Rust
Install the crate, run the pipeline, define a tag, and read the errors. Every snippet on this page is compiled and run by the repository's test suite.
Install
cargo add accent-proust
The minimum supported Rust version is declared in the manifest and checked
before every release. The bundled CommonMark tokenizer is behind the default
pulldown-cmark-tokenizer feature; see Bring your own
tokenizer below for the other shape.
Render a document
The whole pipeline, in three calls. builtins::config() is Markdoc's own
schemas -- headings, paragraphs, lists, if, table, partial and the rest.
use accent_proust::{builtins, parse, render, transform}; let document = parse::parse("# Title\n\nSome *text*.\n"); let tree = transform::transform(&document, &builtins::config()); assert_eq!( render::render_all(&tree.into_vec()), "<article><h1>Title</h1><p>Some <em>text</em>.</p></article>" );
parse never fails. A malformed tag is a node in the tree carrying an error,
not a Result::Err, because a document that is half-written is the normal state
of a document being written.
Define a tag
A tag needs a schema before it validates or renders. render names the element
to emit; declared attributes reach the output, undeclared ones are an error.
use std::sync::Arc; use accent_proust::validate::{self, MapSchemaSource, Schema, SchemaAttribute, ValidationType}; let mut schemas = MapSchemaSource::builtin(); schemas.insert_tag( "callout", Schema::new().render("div").attribute( "type", SchemaAttribute { attribute_type: Some(ValidationType::String), required: true, ..SchemaAttribute::default() }, ), ); let config = builtins::config_with(Arc::new(schemas)); let document = parse::parse("{% callout type=\"note\" %}\nBody\n{% /callout %}\n"); assert!(validate::validate_tree(&document, &config).is_empty()); let tree = transform::transform(&document, &config); assert_eq!( render::render_all(&tree.into_vec()), "<article><div type=\"note\"><p>Body</p></div></article>" );
The source is shared, not copied. Config holds its schemas, functions and
partials behind an Arc, so filling a MapSchemaSource once and cloning the
config per document is cheap -- which is the shape a static site generator
wants, since it scopes the same config a few thousand times. config_with
takes the Arc so that one registry can back many configs, and builds the
built-in schemas once; Config::with_schemas swaps the source on a config you
already hold.
Read the errors
Validation errors are data, not failures. You get a Vec, so an editor shows
every problem at once instead of the first one.
let document = parse::parse("{% callout %}\nBody\n{% /callout %}\n"); for error in validate::validate_tree(&document, &config) { println!("{}: {}", error.error.id, error.error.message); // attribute-missing-required: Missing required attribute: 'type' }
error.id is upstream Markdoc's id, unchanged. That is the field external
tooling binds to, so it is the field this crate is least free to invent. Each
error also carries the byte range of the node it came from, which is what an
editor needs to underline the offending span.
Format
format prints a tree as canonical Markdoc source. It normalises spacing inside
a tag and leaves your own spellings alone, so __bold__ stays __bold__.
use accent_proust::format; let document = parse::parse("{% callout type=\"note\" %}\nBody\n{% /callout %}\n"); assert_eq!( format::format(&document), "{% callout type=\"note\" %}\nBody\n{% /callout %}\n" );
Two properties make this safe to run over a file in place:
format(parse(s)) is idempotent, and parse(format(ast)) gives back the same
tree. The first means a formatter can run twice without churn; the second means
formatting loses nothing.
Variables, functions and partials
Those three live on Config alongside the schemas.
| Field | Holds | Note |
|---|---|---|
variables | What $name resolves against | None switches variable checking off entirely; Some of an empty map switches it on with nothing defined |
functions | What f() calls resolve against | A ConfigFunction declares its parameters so the validator can check a call |
partials | Parsed documents, keyed by the name {% partial file="..." %} uses | Parsed, not raw -- the crate does no I/O, so the host reads the file and parses it |
That last row is why Config carries a lifetime: it borrows the partial
documents the host parsed.
Note
The distinction between variables: None and variables: Some(empty) is load
bearing. A tool that does not know the host's variables should use None and
report nothing, rather than report every $reference in the document as
undefined.
Bring your own tokenizer
Tokenizer is the one real trait seam in the crate. The bundled implementation
uses pulldown-cmark, behind the default feature:
accent-proust = { version = "*", default-features = false }
Turn the feature off and implement Tokenizer if you already parse CommonMark,
or if you pin pulldown-cmark to a git revision -- Cargo treats that as a
different package, so you would otherwise compile two CommonMark parsers into
one binary and render some documents through each.
use accent_proust::parse::tokenizer::{Spanned, Tokenizer}; struct MyTokenizer; impl Tokenizer for MyTokenizer { fn tokenize<'s>(&self, source: &'s str) -> Vec<Spanned<'s>> { // Emit Start/End events over byte ranges into `source`. todo!() } }
The contract is short and strict: every Start is matched by an End of the
same kind and properly nested, ranges are byte ranges into source that are
non-decreasing in start order and land on character boundaries, and a
container's range covers its delimiters as well as its content -- the layer
above reads markers back out of the source, so an emphasis node's marker
attribute is the * or _ at the start of its span.
A CI job builds and tests the crate with the feature off, so this shape is supported rather than tolerated.
What the crate will not do for you
No I/O, no configuration file, no concept of a theme, a template or a plugin. Two responsibilities in particular are the host's, each behind a trait:
Where a schema comes from. validate::SchemaSource. MapSchemaSource is
the implementation you fill by hand, as above; a host whose schemas live
somewhere else -- a component registry, a sandboxed guest -- implements the
trait and hands Config the result. Whether they came from a constant, a
YAML file or a database is the implementation's business, and the crate never
reads a file to find out.
HTML policy. render::render_all emits upstream's markup through
render::Html, one implementation of render::TagRenderer. A host that wants
different elements, different escaping or a different void-element list
implements the trait and calls render_with. It writes the markup for one tag
at a time -- open, close, text -- and the crate walks the tree, so the
document's depth is never on the host's stack.
Reference
The generated API documentation is on
docs.rs/accent-proust. Start at the crate root:
the module docs there state the conventions the whole surface commits to,
including why every public enum is #[non_exhaustive] and what panic-freedom
means for values you build yourself.