htmlp

Rust interface

One primitive in two syntaxes. Markup and Rust build the same tree.

An Element has a name, an optional id, optional budgets, and children — the same things a start tag carries. Nothing in the library assigns provider roles, message kinds, or trust levels; your application decides what a name means.

Install

[dependencies]
htmlp = { git = "https://github.com/alexmckenley/htmlp", tag = "v0.3.0-alpha.1" }

The default library has no provider SDK, async runtime, networking, or tokenizer tables. Add tokens for Cl100k and json for serialization. See all library features.

Build a document

use htmlp::{Document, Element};

let workflow = Element::new("workflow")
    .id("routine")
    .max_tokens(500, "Keep routine steps short.")
    .text("Read the code. Make one change. Run its tests.");
let mut document = Document::new(2_000, "Loaded on every request.", vec![workflow.into()]);
document.sign(); // Explicitly accept the budgets authored above.

Element::new(name) accepts any valid name: lowercase ASCII letters, digits, and hyphens, starting with a letter. htmlp is reserved for the root. The name is the element's kind; the optional id is its identity. max_tokens and per_item each require a reason and clear any existing signature, so changing a budget in Rust also requires Document::sign().

Builder methods append in call order: text adds literal text, variable adds a placeholder, element nests a child, and template parses interpolation. Node::text, Node::variable, and From<Element> construct nodes directly.

Interpolate like a file

template accepts the same syntax as markup text and produces the same nodes, so a Rust-built document and a parsed one differ only in source positions:

use htmlp::{Bindings, Cl100k, Document, Element, parse};

let answer = Element::new("answer")
    .template("Answer: {{question}}")
    .expect("valid template");
let mut built = Document::new(1_000, "Request context.", vec![answer.into()]);
built.sign();
let parsed = parse(
    r#"<htmlp max-tokens="1k" reason="Request context." sig="a66b5750507d50df"><answer>Answer: {{question}}</answer></htmlp>"#,
)
.expect("valid markup");

let counter = Cl100k::new().expect("tokenizer");
let bindings = Bindings::from([("question".into(), "What changed?".into())]);
assert_eq!(
    built.render(&bindings, &counter).expect("valid").text(),
    parsed.render(&bindings, &counter).expect("valid").text(),
);

Only {{name}} is special. Other characters, including < and &, stay literal: template does not decode entities, because entity syntax belongs to the file format. Use text for content containing literal braces, and parse_template(source) for nodes without an element wrapper. Template errors carry line, column, and byte offset like parse errors.

Render and check

use htmlp::{Bindings, Cl100k, parse_file};

let document = parse_file("rules.htmlp").expect("valid markup");
let counter = Cl100k::new().expect("tokenizer");
let bindings = Bindings::from([("question".into(), "What changed?".into())]);
let rendered = document
    .render(&bindings, &counter)
    .expect("valid signatures, bindings, and budgets");

Handle diagnostics in your application; expect keeps these examples short. Document::render is the checked entry point, and the free function render_checked(&document, &bindings, &counter) is the same thing. It returns a RenderedDocument only after validating element names, unique ids, budget signatures, every binding, and every root, element, and per-item budget. No constructor or deserializer can fabricate checked output, and failure returns diagnostics instead of partial text.

parse and parse_file validate syntax only. lint checks structure and fully static budgets; rendering also binds variables and enforces final budgets. to_string() is an unchecked text view showing unbound variables as {{name}}.

Read checked output

MethodReturns
text(), into_text()The final text, borrowed or owned.
measurement()TextMeasurement: tokenizer identifier and total count.
element(id)One element's text, selected only after the whole document passed.
elements_by_name(name)Every element with that name, in source order.
elements()Every RenderedElement: origin and byte range, including empty ones.
spans()Non-overlapping RenderedSpan ranges with element ancestry.
report()Per-element budget Measurements.

ElementOrigin carries name, path, id, and position. path is the chain of child indices from the document root, so anonymous elements remain identifiable and two elements sharing a name never collapse into one. Selecting one element cannot skip its ancestors' constraints, because selection happens after the whole document validates.

Per-element ranges nest and may overlap; spans() partitions the text exactly once. Budget measurements include descendants, so summing a parent and its children double-counts. Group by spans() when attributing tokens to categories.

Names, ids, and your own types

Names and ids are labels. HTMLP has no built-in system-prompt, user-message, or tool-definition element, and derives nothing from a name you choose. An application that needs those distinctions defines them in its own type system and attaches them to rendered output:

enum Category {
    SystemPrompt,
    ContextBlock { block_id: String },
}

Then pair a Category with rendered.element("routine") or with the whole document's text(). Two guarantees stay separate: HTMLP proves the text is within budget, and your types prove the text is attributed. A root element named system-prompt still carries no role — the name documents intent for a human reader and for elements_by_name lookup.

0.2 shipped an optional runtime feature with typed model requests, roles, and content-source categories. That made one harness's vocabulary a public contract of the format, so 0.3 removes it. Move those types into your own crate; roles, tool-call identity, request settings, cache behavior, provider framing, and usage accounting all belong to the caller, who can change them without a format release.

Tokenizers

TokenCounter is a trait with name() and count(text). Linting requires the counter's name to match the document's declared tokenizer, which defaults to cl100k_base. Enable tokens for Cl100k, or implement the trait against your provider's tokenizer.

Counts exclude provider message framing, tool schemas, and SDK overhead. Text token counts are not additive across concatenation boundaries, which is why rendering re-tokenizes each complete subtree after substitution rather than adding up static counts.

Reference

Run it with cargo run --locked --example composition --features tokens. Generate API documentation with cargo doc --all-features --no-deps. The schema describes serialized document shape; semantic invariants still require lint or rendering. Other languages can use the CLI's JSON output; native bindings are future work.