Skip to content

Post-process the Rust AST

Ferromark parses Markdown into a Document allocated in an arena. The HTML renderer consumes that document. When a Rust application needs to change its content between those steps, the optional ferromark-transforms crate provides an ordered pipeline of passes over the native AST.

A Remark-like shape, with a different contract

If you know Remark and unified, the staged model will feel familiar: parse content into a syntax tree, run transforms, then produce output. In unified, plugins are composed on a processor; its plugin guide shows that model.

Ferromark shares this processing shape, not the Remark ecosystem. A TransformPass mutates Ferromark's Rust Document in place. Its API is not compatible with JavaScript mdast or remark plugins. The parser remains responsible for Markdown syntax, and the HTML renderer remains responsible for output. Passes do not add syntax, load Remark plugins, or stringify a Markdown tree back to Markdown. This is a conceptual comparison, not a compatibility claim.

Markdown source
    → Ferromark parser
    → arena-backed Document
    → optional ordered TransformPass values
    → optional caller-managed AST additions
    → Ferromark HTML renderer
    → HTML

Add only the passes you need

ferromark-transforms is a separate Rust crate. The core ferromark crate does not depend on it, and the current Node.js bindings do not expose this pipeline. Core parsing and rendering do not run transform passes implicitly; your application opts in by constructing and running a TransformPipeline.

Each pass is an ordinary Rust type that implements TransformPass. The pipeline calls passes in the order you add them. A configured pipeline can be reused for separate documents, but it stores no references to a document or arena. The custom pass example shows the complete parse → pass → render flow. The ferromark-transforms README documents its helpers and error contract.

If a pass returns an error, later passes do not run. A pass may already have changed the document before it fails, so discard or reparse that document; the pipeline does not roll back mutations. The document and all arena-backed nodes must also be dropped before the allocator is reset.

Build derived content after transforms

Run passes before asking for data derived from headings, such as the outline. This way, the outline and heading IDs describe the document that will be rendered. Use the same heading-ID settings for the outline and renderer.

Ferromark's optional TOC helper builds a nested list node from that outline. It does not scan for [[toc]], change the parser, or choose a location. The caller inserts the returned node wherever it belongs. In a fallible Rust function, the flow looks like this:

use ferromark::{Allocator, HtmlRenderer, OutlineOptions, Parser};
use ferromark_transforms::build_table_of_contents;

fn render_with_toc(source: &str) -> Result<String, Box<dyn std::error::Error>> {
    let allocator = Allocator::for_source_len(source.len());
    let mut document = Parser::new(&allocator, source).parse()?;

    // Run configured TransformPass values here, before reading the outline.
    let outline = document.outline(&OutlineOptions::default());
    if let Some(toc) = build_table_of_contents(&allocator, &outline)? {
        document.children.insert(0, toc);
    }

    let mut renderer = HtmlRenderer::new();
    Ok(renderer.render(&document))
}

An empty outline produces no node; an entry without an ID is an error. The helper handles one complete document, not stateful committed or provisional fragments. Generated nodes use Span::empty(), which means no source range is available; it is not a general marker for generated content. See the outline and TOC API guide for the full API contract and the TOC placement decision for the contract.

Use the direct HTML helpers when you only need rendered output. Use AST passes when your application needs to inspect or change the structured document before rendering. For the Rust ownership and rendering workflow, see Build a Rust Content Pipeline.