claude-scriptorium 0.1.5

Render Claude Code sessions as self-contained HTML
Documentation
#!/usr/bin/env just --justfile

set dotenv-load
set ignore-comments

pre-commit-args := ""
cargo-test-args := ""
node-test-args := ""
watch-paths := "src Cargo.toml justfile"
bench-fixtures := "tests/fixtures/session.jsonl,tests/fixtures/playground.jsonl"
bench-warmup := "3"
huge-megabytes := "5 20"
profile-rate := "2000"
profile-iterations := "10"

fonts-dir := "src/fonts"
junicode-ref := "v2.226"
firacode-version := "6.2"
unifrakturcook-ref := "e98889587d96e21ad9d93030fe37ccd9f6c50995"

[default]
[doc("List available recipes")]
list:
    @just --list

[doc("Initial repo setup after cloning")]
setup:
    rustup toolchain install nightly --component rustfmt
    uvx pre-commit install
    npm ci
    npx playwright install chromium

[doc("Run tests")]
[group("rust")]
test *args:
    cargo test --all-features {{ cargo-test-args }} {{ args }}

alias t := test

# The folio's own script is two files: a pure core, exercised here without a
# browser, and the shell that wires it to the document, exercised in one below.

[doc("Test the folio's script without a browser")]
[group("js")]
test-js *args:
    node --test {{ node-test-args }} "tests/js/*.test.mjs" {{ args }}

[doc("Drive a rendered folio in a headless browser")]
[group("js")]
test-browser *args:
    node --test {{ node-test-args }} "tests/browser/*.test.mjs" {{ args }}

# rustfmt.toml uses unstable options, which only nightly rustfmt honours.

[doc("Format Rust code")]
[group("rust")]
format:
    cargo +nightly fmt

[doc("Lint Rust code, fixing what clippy can fix on its own")]
[group("rust")]
lint:
    cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged
    cargo clippy --all-targets --all-features -- -D warnings

# Optimized on purpose: a render is heavy enough (highlighting, base64'ing the
# fonts, megabytes of markup) that an unoptimized build takes longer to render
# than an optimized one takes to compile and render.

[doc("Render a session to an HTML file")]
[group("rust")]
render *args:
    cargo run --release -- render {{ args }}

alias r := render

[doc("Render a session and publish it as a gist")]
[group("rust")]
publish *args:
    cargo run --release -- publish {{ args }}

alias p := publish

[doc("Benchmark rendering the fixtures, e.g. `just bench --export-markdown bench.md`")]
[group("rust")]
bench *args:
    #!/usr/bin/env bash
    set -euo pipefail
    if ! command -v hyperfine >/dev/null 2>&1; then
      echo "just bench needs hyperfine (cargo install hyperfine / brew install hyperfine)" >&2
      exit 1
    fi
    cargo build --release -q
    out="$(mktemp -d)"
    trap 'rm -rf "$out"' EXIT
    # Timed through the binary rather than in-process, so a run measures the
    # whole artifact a reader waits on: parse, render, and write.
    hyperfine --warmup {{ bench-warmup }} --parameter-list fixture {{ bench-fixtures }} {{ args }} \
      "target/release/claude-scriptorium render {fixture} -o $out/"

alias b := bench

# The committed fixtures are short, where a session that runs for megabytes
# spends its time differently: a language's regexes compile once, so length
# amortizes what dominates a small render. These are written rather than
# committed, being millions of bytes of dealt-out fixture turns.

[doc("Write the large generated benchmark sessions under target/fixtures")]
[group("rust")]
fixtures:
    #!/usr/bin/env bash
    set -euo pipefail
    for megabytes in {{ huge-megabytes }}; do
      uv run scripts/synthetic_session.py --megabytes "$megabytes" \
        --output "target/fixtures/huge-${megabytes}mb.jsonl"
    done

[doc("Benchmark rendering the large generated sessions, e.g. `just bench-huge --runs 3`")]
[group("rust")]
bench-huge *args: fixtures
    #!/usr/bin/env bash
    set -euo pipefail
    sessions=""
    for megabytes in {{ huge-megabytes }}; do
      sessions+="${sessions:+,}target/fixtures/huge-${megabytes}mb.jsonl"
    done
    just bench-fixtures="$sessions" bench-warmup=1 bench --runs 5 {{ args }}

# perf and two kernel settings, written under /etc/sysctl.d so they survive a
# reboot (and, under WSL, a shutdown of the VM). `perf_event_paranoid` decides
# whether an unprivileged user may sample a process it owns at all;
# `perf_event_max_stack` is how many frames the kernel walks per sample, and its
# default of 127 truncates a render's deeply recursive regex compilation.

[doc("Install perf and the kernel settings `just profile` needs (needs sudo)")]
[group("rust")]
[linux]
setup-profiling:
    #!/usr/bin/env bash
    set -euo pipefail
    if ! ls /usr/lib/linux-tools/*/perf >/dev/null 2>&1 && ! command -v perf >/dev/null 2>&1; then
      command -v apt-get >/dev/null 2>&1 \
        || { echo "install perf with your distribution's package manager, then rerun" >&2; exit 1; }
      sudo apt-get install -y linux-tools-generic
    fi
    printf '%s\n' 'kernel.perf_event_paranoid = 1' 'kernel.perf_event_max_stack = 1024' \
      | sudo tee /etc/sysctl.d/99-perf-event.conf > /dev/null
    sudo sysctl --system > /dev/null
    sysctl kernel.perf_event_paranoid kernel.perf_event_max_stack

# Sampled through the `profiling` build (release code plus the debug info a
# profiler needs to name a frame), over several iterations, since one render is
# over in a couple of hundred milliseconds and a hundred samples say nothing.
#
# Recorded by perf rather than samply's own sampler, and built with frame
# pointers for it to walk: samply unwinds a fixed 32 KB copy of each sample's
# stack and loses every frame above that, which here is four fifths of them, so
# a stack would root in the middle of regex compilation and the inclusive
# figures would undercount. perf walks the frame-pointer chain in the kernel
# with no such window. samply still reads the recording, for the report and for
# `samply load`.

[doc("Profile rendering a session and print a report, e.g. `just profile tests/fixtures/session.jsonl`")]
[group("rust")]
profile *args:
    #!/usr/bin/env bash
    set -euo pipefail
    for tool in samply uv; do
      command -v "$tool" >/dev/null 2>&1 || { echo "just profile needs $tool (see mise.toml)" >&2; exit 1; }
    done
    # Ubuntu's /usr/bin/perf is a wrapper that refuses to run when it has no
    # build matching the running kernel, which is every WSL kernel; the
    # versioned binary it declines to exec works fine.
    perf="$(command -v perf || true)"
    if [[ -z "$perf" ]] || ! "$perf" --version > /dev/null 2>&1; then
      perf="$(ls /usr/lib/linux-tools/*/perf 2> /dev/null | head -1)"
    fi
    [[ -x "${perf:-}" ]] || { echo "just profile needs perf: run just setup-profiling" >&2; exit 1; }
    session="{{ args }}"
    session="${session:-tests/fixtures/playground.jsonl}"
    RUSTFLAGS="-Cforce-frame-pointers=yes" cargo build --profile profiling -q
    out="$(mktemp -d)"
    trap 'rm -rf "$out"' EXIT
    mkdir -p target/profile
    render="target/profiling/claude-scriptorium render $session -o $(printf %q "$out")/ > /dev/null"
    "$perf" record --call-graph fp --freq {{ profile-rate }} --output target/profile/perf.data \
      -- bash -c "for _ in \$(seq {{ profile-iterations }}); do $render; done"
    samply import target/profile/perf.data --save-only --no-open \
      -o target/profile/profile.json > /dev/null
    uv run scripts/profile_report.py target/profile/profile.json \
      --binary target/profiling/claude-scriptorium --label "render $session" \
      | tee target/profile/report.txt
    echo >&2
    echo "report: target/profile/report.txt" >&2
    echo "flamegraph and timeline: samply load target/profile/profile.json" >&2

[doc("Serve a session with live reload, rebuilding and restarting on source changes")]
[group("rust")]
serve *args:
    #!/usr/bin/env bash
    set -uo pipefail
    if ! command -v fswatch >/dev/null 2>&1; then
      echo "just serve needs fswatch (apt install fswatch / brew install fswatch)" >&2
      exit 1
    fi
    bin="target/release/claude-scriptorium"
    trap 'kill "${pid:-}" 2>/dev/null || true' EXIT
    while true; do
      pid=""
      if cargo build --release; then
        "$bin" serve {{ args }} &
        pid=$!
      else
        echo "build failed; waiting for changes" >&2
      fi
      fswatch -1 {{ watch-paths }} >/dev/null
      [[ -n "$pid" ]] && kill "$pid" 2>/dev/null
      [[ -n "$pid" ]] && wait "$pid" 2>/dev/null
    done

alias s := serve

[doc("Rerun a recipe when sources change, e.g. `just watch render <session>`")]
[group("rust")]
watch recipe *args:
    #!/usr/bin/env bash
    set -euo pipefail
    if ! command -v fswatch >/dev/null 2>&1; then
      echo "just watch needs fswatch (apt install fswatch / brew install fswatch)" >&2
      exit 1
    fi
    run() { just {{ recipe }} {{ args }} || true; }
    run
    fswatch -o {{ watch-paths }} | while read -r _; do run; done

alias w := watch

[doc("Vendor the embedded web fonts and their licenses from upstream")]
[group("rust")]
fonts:
    #!/usr/bin/env bash
    set -euo pipefail
    for tool in curl unzip uv uvx; do
      command -v "$tool" >/dev/null 2>&1 || { echo "just fonts needs $tool" >&2; exit 1; }
    done
    dir="{{ fonts-dir }}"
    lic="$dir/licenses"
    mkdir -p "$lic"
    tmp="$(mktemp -d)"
    trap 'rm -rf "$tmp"' EXIT
    get() { echo "  ${2##*/}" >&2; curl -sSfL "$1" -o "$2"; }

    # Upstream lands in $tmp; scripts/subset_fonts.py cuts it down into $dir,
    # since a folio inlines every face and upstream coverage is ~98% of one.
    echo "vendoring:" >&2

    # Junicode 2 (SIL OFL): humanist medievalist serif for body text. One
    # variable file per posture spans every weight; italic is a separate face.
    juni="https://raw.githubusercontent.com/psb1558/Junicode-font/{{ junicode-ref }}"
    get "$juni/webfiles/JunicodeVF-Roman.woff2" "$tmp/JunicodeVF-Roman.woff2"
    get "$juni/webfiles/JunicodeVF-Italic.woff2" "$tmp/JunicodeVF-Italic.woff2"
    get "$juni/OFL.txt" "$lic/Junicode-OFL.txt"

    # Fira Code (SIL OFL): monospace for code and tool output. The variable
    # woff2 ships only inside the release zip; its license lives in the repo.
    fira="https://github.com/tonsky/FiraCode"
    get "$fira/releases/download/{{ firacode-version }}/Fira_Code_v{{ firacode-version }}.zip" "$tmp/fira.zip"
    unzip -p "$tmp/fira.zip" "woff2/FiraCode-VF.woff2" > "$tmp/FiraCode-VF.woff2"
    echo "  FiraCode-VF.woff2" >&2
    get "https://raw.githubusercontent.com/tonsky/FiraCode/{{ firacode-version }}/LICENSE" "$lic/FiraCode-OFL.txt"

    # UnifrakturCook (SIL OFL): single-weight blackletter for headings. Cyreal's
    # repo is the upstream and carries the license beside the font, but ships a
    # TTF, so compress it to woff2 here.
    uc="https://raw.githubusercontent.com/cyrealtype/UnifracturCook/{{ unifrakturcook-ref }}"
    get "$uc/UnifrakturCook-Regular-nohints.ttf" "$tmp/UnifrakturCook.ttf"
    get "$uc/OFL.txt" "$lic/UnifrakturCook-OFL.txt"
    echo "  UnifrakturCook.woff2" >&2
    uvx --with brotli --from fonttools fonttools ttLib.woff2 compress \
      -o "$tmp/UnifrakturCook.woff2" "$tmp/UnifrakturCook.ttf"

    echo "cutting down:" >&2
    uv run scripts/subset_fonts.py "$tmp" "$dir"

[doc("Clean build artifacts")]
[group("rust")]
clean:
    cargo clean

[doc("Upgrade Rust dependencies")]
[group("rust")]
upgrade:
    cargo update

# Sequential on purpose: `cargo fmt` and `cargo clippy --fix` rewrite the same
# files, so running them concurrently races.

[private]
fix-code: format lint

[doc("Run pre-commit checks")]
[group("checks")]
fix:
    git add --update
    uvx pre-commit run {{ pre-commit-args }}

alias f := fix

[doc("Run all checks (formatting, linting, testing)")]
[group("checks")]
check: fix test test-js test-browser

alias c := check

[doc("Upgrade pre-commit hooks")]
[group("checks")]
pre-commit-upgrade:
    uvx pre-commit autoupdate