poka.sh

Docs

Write a viewer

tutorial

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 *Ast you have in mind. The catalogue is build/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-wasip1 target (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 *Ast and 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 *Ast itself.

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:

  1. An undecodable input still produces HTML. error_html prints 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.
  2. 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.
  3. THEME_CSS goes first in <style>, and your own rules use var(--fg) and the other tokens, so the preview follows light and dark mode.
  4. Nothing reaches the network. If the viewer draws an image, it embeds it: SVG inline, raster as a base64 data: URL.
  5. One shared card per preview. card draws the file's name, size and dates, the sections you pass it and the parser's facts; the host fills in the file and engine facts. Do not print ast.filename or ast.file_size yourself. 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
}
  • id equals the directory name.
  • kind and role are both "viewer". The hosts read role, the spec describes kind, and the validator requires both.
  • file_extension is the empty string. A manifest that claims an extension is routed as a parser.
  • wasm.path is the crate's output file name, <package>.wasm, with no directory.
  • memory_mb and max_time_ms are 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 sha256 and size out. install-extensions.py --stamp-hashes --write-back fills 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.py checks every viewers/*/plugin.json against the schema, and that each crate builds the file its manifest declares. Its optional argument is the directory that holds the plugins (default viewers/), 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.py checks that every viewer id a parser names resolves to a viewer here. It needs the poka-parsers sibling.
  • viewer-inventory.py --write regenerates build/viewer-inventory.md. Commit the result: CI fails when the file is stale. Adding the crate also changes the workspace Cargo.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.