Write a viewer
This tutorial takes you from nothing to a new viewer crate that turns an *Ast
into HTML and is built, validated and installed for Poka. It follows one path:
a small viewer for a message that already exists in preview.proto.
Read SPEC.md alongside this page. It is the normative contract: manifest,
WASI ABI, output rules, discovery. Where this tutorial and SPEC.md differ,
SPEC.md wins.
Before you start
- Check that no existing viewer already renders the
*Astyou have in mind. The catalogue isbuild/viewer-inventory.md. Most new formats need a parser only: the parser names an existing viewer in its manifest, and the viewer is reused. - A Rust toolchain with the
wasm32-wasip1target (rustup target add wasm32-wasip1). - The four repos checked out side by side (see the workspace
CLAUDE.md). - Work in a worktree, not in the primary checkout (see
CONTRIBUTING.md).
1. Know what a viewer is
A viewer is a WASI command module. The host runs the parser, takes the bytes the
parser wrote to stdout (one *Ast message) and writes them to the viewer's
stdin. The viewer writes UTF-8 HTML to stdout and exits. That is the whole
interface:
- A viewer reads one
*Astand nothing else. It never parses a file and never knows which parser produced the message. - A viewer has no
file_extension. A parser names it by id. - There is no request envelope. The bytes on stdin are the
*Astitself.
The reasons for the HTML rules are on the output contract page.
2. Create the crate
Viewers live in viewers/poka-<name>-viewer/. The workspace Cargo.toml takes
every directory under viewers/ as a member, so there is nothing to register.
Copy an existing crate with a simple input. poka-text-viewer is the shortest
real one. Your Cargo.toml is:
[package]
name = "poka-example-viewer"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
poka-viewer-common = { path = "../poka-viewer-common" }
prost.workspace = true
poka-preview-proto.workspace = true
There is no build.rs in a viewer. The poka-preview-proto crate compiles
core/proto/preview.proto once for the whole workspace, and every viewer
reaches the generated types through the mod proto block below.
3. Write main.rs
Start with a //! header. It says what the viewer takes, what it draws and what
it leaves out; it is the first thing the next person reads and the docs
generator uses it for the viewer's reference page. Then the three parts every
viewer has: read stdin, decode, render.
//! Poka viewer: render a `TextAst` on stdin as numbered lines of HTML on stdout.
mod proto {
pub use poka_preview_proto::preview;
}
use poka_viewer_common::{card, error_html, html_escape, row, THEME_CSS};
use prost::Message;
use proto::preview::*;
use std::io::{self, Read, Write};
fn main() {
let mut input = Vec::new();
let html = match io::stdin().read_to_end(&mut input) {
Err(e) => error_html("example-viewer", &format!("Read TextAst from stdin: {e}")),
Ok(_) => match TextAst::decode(input.as_slice()) {
Err(e) => error_html("example-viewer", &format!("Decode TextAst: {e}")),
Ok(ast) => render(&ast),
},
};
let _ = io::stdout().write_all(html.as_bytes());
}
fn render(ast: &TextAst) -> String {
let mut out = format!("<style>{THEME_CSS}</style>");
out.push_str("<pre>");
for (i, line) in ast.content.lines().enumerate() {
out.push_str(&format!("{:>4} {}\n", i + 1, html_escape(line)));
}
out.push_str("</pre>");
out.push_str(&card(&row("Type", &ast.source_format, ""), ast.facts.as_ref()));
out
}
Five habits are in that code, and each one is a rule:
- An undecodable input still produces HTML.
error_htmlprints a one-line message in the host theme. The viewer does not panic and does not exit with an error; a panic kills the module and the user sees a failure instead of a preview. - Every string from the file goes through
html_escape. The filename, the cell text, the author field: all of it. Escaping is the viewers' main security boundary. See Follow the host theme and escape file content. THEME_CSSgoes first in<style>, and your own rules usevar(--fg)and the other tokens, so the preview follows light and dark mode.- Nothing reaches the network. If the viewer draws an image, it embeds it:
SVG inline, raster as a base64
data:URL. - One shared card per preview.
carddraws the file's name, size and dates, the sections you pass it and the parser'sfacts; the host fills in the file and engine facts. Do not printast.filenameorast.file_sizeyourself. See the output contract.
4. Write the manifest
plugin.json sits next to Cargo.toml:
{
"id": "poka-example-viewer",
"name": "Example Viewer",
"version": "1.0.0",
"kind": "viewer",
"role": "viewer",
"file_extension": "",
"wasm": {
"path": "poka-example-viewer.wasm",
"memory_mb": 32,
"max_time_ms": 5000
},
"abi": 1
}
idequals the directory name.kindandroleare both"viewer". The hosts readrole, the spec describeskind, and the validator requires both.file_extensionis the empty string. A manifest that claims an extension is routed as a parser.wasm.pathis the crate's output file name,<package>.wasm, with no directory.memory_mbandmax_time_msare the sandbox caps. A viewer that exceeds either is killed and reported. The existing viewers use 32 MB and 5000 ms, with 64 MB for the image, ebook, chart and 3D viewers and 256 MB for the tree viewer.- Leave
sha256andsizeout.install-extensions.py --stamp-hashes --write-backfills them in (step 6).
The full list of fields is in the manifest reference and in
build/viewer-plugin.schema.json.
5. Build and validate
From the repository root:
cargo build --release -p poka-example-viewer --target wasm32-wasip1
python3 build/scripts/validate-viewer-plugin.py
python3 build/scripts/validate-viewer-ids.py --parsers ../poka-parsers/parsers
python3 build/scripts/viewer-inventory.py --write
Run these from the repository root so that .cargo/config.toml applies: every
build writes to the one shared target directory viewers/.wasm-target/. Do not
set CARGO_TARGET_DIR.
validate-viewer-plugin.pychecks everyviewers/*/plugin.jsonagainst the schema, and that each crate builds the file its manifest declares. Its optional argument is the directory that holds the plugins (defaultviewers/), not a single plugin, so run it without one. To require the built wasm as well, build the whole workspace and add--artifacts viewers/.wasm-target/wasm32-wasip1/release.validate-viewer-ids.pychecks that every viewer id a parser names resolves to a viewer here. It needs thepoka-parserssibling.viewer-inventory.py --writeregeneratesbuild/viewer-inventory.md. Commit the result: CI fails when the file is stale. Adding the crate also changes the workspaceCargo.lock; commit that too.
6. Install and see it in Poka
python3 build/scripts/install-extensions.py
This wires the built wasm and plugin.json into the host's extension
directory (~/.poka/extensions/ on Linux and macOS, %APPDATA%\Poka\extensions\
on Windows). By default it links the wasm, so re-running it after a rebuild picks up the
change. --stamp-hashes copies the wasm instead and records sha256 and size
in the installed manifest; with --write-back it also patches them into the
manifest in viewers/.
A viewer is only reached through a parser. To see yours, a parser's
plugin.json must name it in its viewer field and emit the matching *Ast.
Which *Ast a parser emits and how to set the viewer is in the poka-parsers
tutorial "Write a parser" and in the poka skill in poka-interfaces.
7. Add tests
Test render as a plain function in #[cfg(test)] mod tests in main.rs. Build
an *Ast in code, call render, assert on the HTML. Always add the hostile
string test, which feeds attacker-shaped text into every field the viewer
shows. The recipe is in Test a viewer, which also notes the
hard-coded viewer count in poka-viewer-common's theme test that a new viewer
changes.
What you have now
A crate that reads an *Ast, draws it in the host theme, escapes the file's
text and fails softly; a manifest the validator accepts; a coverage table that
lists it. For the rules behind the HTML, read the output contract.