diff --git a/.abacus/config.yaml b/.abacus/config.yaml new file mode 100644 index 0000000..0c588c7 --- /dev/null +++ b/.abacus/config.yaml @@ -0,0 +1,2 @@ +beads: + backend: bd diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0fcd6c9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,66 @@ +{ + "permissions": { + "allow": [ + "Read(//home/lox/code/book.cftw/**)", + "WebSearch", + "Bash(bd onboard:*)", + "Bash(bd list:*)", + "Bash(tree:*)", + "Bash(bd init:*)", + "Bash(bd create:*)", + "Bash(bd dep add:*)", + "Bash(bd update:*)", + "Bash(bd ready:*)", + "Bash(cargo check:*)", + "Bash(cargo clean:*)", + "Bash(cargo build:*)", + "Bash(cargo run:*)", + "Bash(bd close:*)", + "Bash(typst compile:*)", + "Read(//tmp/**)", + "Bash(cargo doc:*)", + "Read(//home/lox/.cargo/registry/src/**)", + "WebFetch(domain:docs.rs)", + "Read(//home/lox/.cargo/git/checkouts/**)", + "Bash(rustc:*)", + "WebFetch(domain:github.com)", + "Bash(find:*)", + "Bash(typst fonts --help:*)", + "Bash(pdffonts:*)", + "Bash(cargo tree:*)", + "Bash(typst fonts:*)", + "Bash(git clone:*)", + "Bash(cat:*)", + "WebFetch(domain:typst.app)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(jj log:*)", + "Bash(jj status:*)", + "Bash(RUST_LOG=rheo=trace cargo run:*)", + "Bash(jj show:*)", + "WebFetch(domain:laurmaedje.github.io)", + "Bash(bd show:*)", + "Bash(jj diff:*)", + "Bash(jj describe:*)", + "Bash(jj new:*)", + "Bash(jj restore:*)", + "Bash(perl -i -pe:*)", + "Bash(cargo test:*)", + "Bash(jj squash:*)", + "Bash(nix build:*)", + "Bash(nix-prefetch-git:*)", + "Bash(nix-prefetch-url:*)", + "Bash(nix hash to-sri:*)", + "Bash(cargo clippy:*)", + "Bash(cargo fmt:*)", + "Bash(gh:*)", + "Bash(xargs cat:*)", + "Bash(jj file list:*)", + "Bash(jj file untrack:*)", + "Bash(just lint:*)", + "Bash(git submodule:*)" + ], + "deny": [], + "ask": [] + }, + "prompt": "Before starting any work, run 'bd onboard' to understand the current project state and available issues." +} diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index c83bab1..a859dee 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -47,7 +47,7 @@ jobs: - name: Setup uses: ./.github/workflows/setup - name: Compile binary - run: cargo ${{ matrix.command }} --locked --target ${{ matrix.target }} + run: cargo ${{ matrix.command }} --locked --target ${{ matrix.target }} -p rheo-cli - name: Compress artifacts (Windows) if: runner.os == 'Windows' run: | @@ -68,7 +68,13 @@ jobs: - name: Login to crates.io run: cargo login ${{ secrets.CRATES_IO_TOKEN }} - name: Dry run of crate publish - run: cargo publish --workspace --dry-run + run: | + cargo publish -p rheo-core --dry-run --allow-dirty || true + cargo publish -p rheo-html --dry-run --allow-dirty || true + cargo publish -p rheo-pdf --dry-run --allow-dirty || true + cargo publish -p rheo-epub --dry-run --allow-dirty || true + cargo publish -p rheo-cli --dry-run --allow-dirty || true + cargo publish -p rheo --dry-run --allow-dirty || true check-pr-name: if: contains(github.event.pull_request.labels.*.name, 'release') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ea305e..c32c22b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - name: Setup uses: ./.github/workflows/setup - name: Compile binary - run: cargo build --locked --release --target ${{ matrix.target }} + run: cargo build --locked --release --bin rheo --target ${{ matrix.target }} - name: Compress artifacts (Windows) if: runner.os == 'Windows' run: | @@ -62,7 +62,12 @@ jobs: - name: Login to crates.io run: cargo login ${{ secrets.CRATES_IO_TOKEN }} - name: Publish crates - run: cargo publish --workspace + run: | + cargo publish -p rheo-core + cargo publish -p rheo-html + cargo publish -p rheo-pdf + cargo publish -p rheo-epub + cargo publish -p rheo-cli - name: Add a tag for the merged commit uses: christophebedard/tag-version-commit@v1 with: diff --git a/CLAUDE.md b/CLAUDE.md index e5dbf6b..6c27fb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,834 +1,176 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Project ---- - -## Project-Specific Configuration - -### Project Description - -**rheo** is a tool for flowing Typst documents into publishable outputs. It compiles Typst files to multiple output formats including PDF, HTML, and EPUB. - -**Architecture:** -- Written in Rust using the Typst compiler as a library -- CLI tool built with clap for command-line argument parsing -- Implements custom `World` trait for Typst compilation with automatic `rheo.typ` import injection -- Uses typst-kit for font discovery and management - -**Key Features:** -- Multi-format compilation (PDF, HTML, and EPUB) -- Project-based compilation (compiles all .typ files in a directory) -- **Incremental compilation in watch mode** using Typst's comemo caching -- Automatic asset copying (CSS, images) for HTML output -- Clean command for removing build artifacts -- Template injection for consistent document formatting -- Configurable default output formats via rheo.toml -- **Smart defaults for EPUB** (automatic title and spine inference) - -**Project Structure:** -- `src/rs/` - Rust source code - - `main.rs` - CLI entry point - - `lib.rs` - Library root - - `cli.rs` - Command-line interface and argument parsing - - `compile.rs` - PDF and HTML compilation logic - - `world.rs` - Typst World implementation for file access - - `project.rs` - Project detection and configuration - - `output.rs` - Output directory management - - `assets.rs` - Asset copying utilities - - `logging.rs` - Logging configuration - - `error.rs` - Error types -- `src/typ/` - Typst template files - - `rheo.typ` - Core template and utilities - -Each project creates its own `build/` directory (gitignored) containing: -- `pdf/` - PDF outputs -- `html/` - HTML outputs -- `epub/` - EPUB outputs - -### Development Commands - -**Build the project:** -```bash -cargo build -``` +**rheo** compiles Typst documents to PDF, HTML, and EPUB. Written in Rust using the Typst compiler as a library. -**Run rheo:** -```bash -# Compile a project directory -cargo run -- compile -cargo run -- compile --pdf # PDF only -cargo run -- compile --html # HTML only -cargo run -- compile --epub # EPUB only - -# Compile a single .typ file -cargo run -- compile # All formats -cargo run -- compile --pdf # PDF only -cargo run -- compile --html # HTML only -cargo run -- compile --epub # EPUB only - -# Examples -cargo run -- compile examples/blog_site # Directory mode -cargo run -- compile examples/blog_site/content/index.typ # Single file mode -cargo run -- compile examples/blog_post --epub # EPUB with defaults - -# Using custom config location -cargo run -- compile examples/blog_site --config /path/to/custom.toml - -# Using custom build directory -cargo run -- compile examples/blog_site --build-dir /tmp/build -``` - -**Additional CLI flags:** -```bash -# --config: Load rheo.toml from custom location (overrides default ./rheo.toml) -cargo run -- compile --config /path/to/config.toml -cargo run -- watch --config /path/to/config.toml +**Source structure:** +- `src/rs/` — Rust: `main.rs`, `cli.rs`, `compile.rs`, `world.rs`, `project.rs`, `output.rs`, `assets.rs` +- `src/typ/rheo.typ` — Core Typst template (auto-injected) +- `build/` — Output dir (gitignored): `pdf/`, `html/`, `epub/` -# --build-dir: Override build directory (takes precedence over rheo.toml setting) -cargo run -- compile --build-dir /tmp/rheo-build -cargo run -- watch --build-dir ./custom-output - -# Both flags work with compile, watch, and clean commands -cargo run -- clean --build-dir /tmp/rheo-build -``` - -**Clean build artifacts:** -```bash -cargo run -- clean # Clean current directory's project -cargo run -- clean # Clean specific project -cargo run -- clean examples/blog_site # Example: clean blog_site project -``` - -**Run with debug logging:** -```bash -RUST_LOG=rheo=trace cargo run -- compile -``` +## Development Commands -**Run tests:** ```bash -# Run all tests +cargo build +cargo run -- compile # all formats +cargo run -- compile --pdf|--html|--epub +cargo run -- compile # single file +cargo run -- watch --open # dev server at localhost:3000 +cargo run -- clean +RUST_LOG=rheo=trace cargo run -- compile ... # debug logging + +# Tests cargo test - -# Run integration tests only cargo test --test harness - -# Update test references (after intentional output changes) -UPDATE_REFERENCES=1 cargo test --test harness - -# Run only HTML tests (across all projects that support it) -RUN_HTML_TESTS=1 cargo test --test harness - -# Run only PDF tests (across all projects that support it) -RUN_PDF_TESTS=1 cargo test --test harness - -# Run only EPUB tests (across all projects that support it) -RUN_EPUB_TESTS=1 cargo test --test harness - -# Increase diff output limit (default: 2000 chars) -RHEO_TEST_DIFF_LIMIT=10000 cargo test --test harness -- --nocapture - -# Run tests sequentially (to avoid parallel conflicts) -cargo test --test harness -- --test-threads=1 -``` - -**Note:** Tests automatically use embedded fonts (`TYPST_IGNORE_SYSTEM_FONTS=1`) for consistent output across environments. This is passed to all subprocess invocations by the test harness. - -See `tests/README.md` for detailed documentation on the integration test suite. - -**Test Suite Features:** -- **Directory Tests**: Full project compilation with rheo.toml -- **Single-File Tests**: Individual .typ files with test markers -- **Test Markers**: Embedded comments in .typ files declaring test metadata -- **Format Filtering**: Environment variables to run only HTML or PDF tests -- **Improved Error Messages**: Detailed diffs with statistics and update commands -- **Hash-Based References**: Prevents conflicts between single-file tests - -### Configuration (rheo.toml) - -Projects can include a `rheo.toml` configuration file in the project root to customize compilation behavior. - -**Example rheo.toml:** -```toml -version = "0.1.2" - -content_dir = "content" - -[compile] -# Default formats to compile when no CLI flags are specified -# Default: ["pdf", "html", "epub"] -formats = ["html", "pdf"] - +UPDATE_REFERENCES=1 cargo test --test harness # update snapshots +RUN_HTML_TESTS=1 / RUN_PDF_TESTS=1 / RUN_EPUB_TESTS=1 cargo test --test harness +cargo fmt && cargo clippy -- -D warnings ``` -**Configuration Precedence:** -- CLI flags (`--pdf`, `--html`, `--epub`) override config file formats -- If no CLI flags are specified, uses `compile.formats` from config -- If `compile.formats` is empty or not specified, defaults to `["html", "epub", "pdf"]` - -### Complete Configuration Reference - -**Full rheo.toml schema with all available options:** +## rheo.toml ```toml -# Manifest version (required) -version = "0.1.2" # Required: Manifest version for rheo.toml API compatibility - # Must be valid semver (e.g., "0.1.2") - # Current supported version: 0.1.2 +version = "0.1.2" # required, must match CLI version +content_dir = "content" # optional +build_dir = "build" # optional +formats = ["html", "pdf", "epub"] # default formats -# Project-level configuration -content_dir = "content" # Directory containing .typ files (relative to project root) - # If not specified, searches entire project root - # Example: "content", "src", "chapters" - -build_dir = "build" # Build output directory (relative to project root unless absolute) - # Defaults to "build/" if not specified - # Examples: "output", "../shared-build", "/tmp/rheo-build" - -formats = ["html", "pdf", "epub"] # Default formats to compile when no CLI flags specified - # Defaults to all three formats if not specified - # Valid values: "html", "pdf", "epub" - -# HTML-specific configuration [html] -stylesheets = ["style.css"] # CSS files to inject into HTML output - # Paths are relative to build/html directory - # Default: ["style.css"] - -fonts = [] # External font URLs to inject into HTML - # Example: ["https://fonts.googleapis.com/css2?family=Inter"] - # Default: [] +stylesheets = ["style.css"] +fonts = [] -# PDF-specific configuration -[pdf] -# Optional: Configure PDF spine for multi-chapter books [pdf.spine] -title = "My Book" # Title for the PDF document -vertebrae = ["cover.typ", "chapters/**/*.typ"] # Glob patterns for files to include - # Patterns evaluated relative to content_dir - # Results sorted lexicographically - # Example patterns: - # - "cover.typ" (single file) - # - "chapters/**" (all files in chapters/) - # - "**/*.typ" (all .typ files recursively) -merge = true # Optional: merge vertebrae into single PDF (default: false) - -# EPUB-specific configuration -[epub] -identifier = "urn:uuid:12345678-1234-1234-1234-123456789012" # Unique global identifier - # Optional, auto-generated if not specified - # Format: URN, URL, or ISBN +title = "My Book" +vertebrae = ["cover.typ", "chapters/**/*.typ"] +merge = true -date = 2025-01-15T00:00:00Z # Publication date (ISO 8601 format) - # Optional, separate from modification timestamp - # Default: current date if not specified +[epub] +identifier = "urn:uuid:..." # optional, auto-generated +date = 2025-01-15T00:00:00Z -# Optional: Configure EPUB spine for multi-chapter books [epub.spine] -title = "My Book" # Title for the EPUB document -vertebrae = ["cover.typ", "chapters/**/*.typ"] # Glob patterns for files to include - # Same format as pdf.spine.vertebrae -``` - -**Configuration Field Details:** - -**Top-level fields:** -- `version` (string, required): Manifest version for rheo.toml API compatibility. Must be valid semver (e.g., "0.1.2"). The manifest version must match the rheo CLI version. Current supported version: 0.1.2 -- `content_dir` (string, optional): Directory containing .typ source files. If omitted, searches entire project root. -- `build_dir` (string, optional): Output directory for compiled files. Defaults to `./build`. -- `formats` (array of strings, optional): Default output formats. Defaults to `["html", "epub", "pdf"]`. - -**[html] section:** -- `stylesheets` (array of strings): CSS files to inject. Paths relative to `build/html/`. Default: `["style.css"]`. -- `fonts` (array of strings): External font URLs to inject into HTML ``. Default: empty. -- `spine` (object, optional): Configuration for HTML output (multiple files, not merged). - - `title` (string, required if spine used): Title for the HTML site. - - `vertebrae` (array of strings, required if spine used): Glob patterns for files to include. - - `merge` (boolean, optional): Ignored for HTML (always produces per-file output). Defaults to None. - -**[pdf] section:** -- `spine` (object, optional): Configuration for merging multiple .typ files into a single PDF. - - `title` (string, required if spine used): Title for the merged PDF. - - `vertebrae` (array of strings, required if spine used): Glob patterns for files to include, sorted lexicographically. - - `merge` (boolean, optional): Whether to merge files into single PDF. Defaults to false. - -**[epub] section:** -- `identifier` (string, optional): Unique identifier for the EPUB (URN, URL, or ISBN). Auto-generated if omitted. -- `date` (datetime, optional): Publication date in ISO 8601 format. Defaults to current date. -- `spine` (object, optional): Configuration for merging multiple .typ files into a single EPUB. - - `title` (string, required if spine used): Title for the merged EPUB. - - `vertebrae` (array of strings, required if spine used): Glob patterns for files to include, sorted lexicographically. - - `merge` (boolean, optional): Ignored for EPUB (always merges). Defaults to None. - -**Precedence rules:** -1. CLI flags (`--pdf`, `--html`, `--epub`, `--config`, `--build-dir`) take highest precedence -2. rheo.toml settings apply if no CLI flags specified -3. Built-in defaults apply if field not specified in rheo.toml - -### Manifest Versioning - -rheo.toml files must include a version field that matches the rheo CLI version. - -- **Required field**: Every rheo.toml must have `version = "0.1.2"` (quoted string) -- **Semantic versioning**: Uses full semver format (major.minor.patch) -- **Exact match required**: rheo warns if config version doesn't match CLI version -- **Current version**: 0.1.2 -- **When to bump**: Manifest version now tracks the CLI version (bumped with each release) - -**Error handling:** -- **Missing version**: Error at config load time with message to add version field -- **Invalid version**: Error with explanation of expected semver format (must be quoted string like "0.1.2") -- **Version mismatch**: Warning (non-fatal) suggesting rheo.toml version update - -### Default Behavior Without rheo.toml - -When no `rheo.toml` exists, rheo automatically infers sensible defaults for EPUB compilation: - -**Title Inference:** -- **Single-file mode**: Derived from filename (e.g., `my-document.typ` → "My Document") -- **Directory mode**: Derived from folder name (e.g., `my_book` → "My Book") - -**EPUB Spine Inference:** -- **Single-file mode**: Just the single file -- **Directory mode**: All `.typ` files sorted lexicographically (equivalent to `**/*.typ` pattern) - -**Format Behavior:** -- **HTML**: Works per-file (one HTML file per `.typ` file) -- **PDF**: Works per-file by default (merge requires explicit config) -- **EPUB**: Always merged (uses inferred title and spine) - -**Example - Single file without config:** -```bash -# Compile a single file to EPUB without any config -cargo run -- compile document.typ --epub -# Generates document.epub with title "Document" +title = "My Book" +vertebrae = ["cover.typ", "chapters/**/*.typ"] ``` -**Example - Directory without config:** -```bash -# Compile a directory to EPUB without any config -cargo run -- compile my_project/ --epub -# Generates my_project.epub with: -# - Title: "My Project" -# - Spine: All .typ files in lexicographic order -``` +Precedence: CLI flags > rheo.toml > built-in defaults. Without rheo.toml, title and spine are inferred from filename/directory. -**Note:** Existing projects with explicit `rheo.toml` configurations are not affected—explicit configs always take precedence over inferred defaults. +## Code Style -### Format Detection in Typst Code +- `cargo fmt` before committing +- `cargo clippy` — fix all warnings +- Errors via `thiserror`, logging via `tracing` macros +- INFO logs: natural language. DEBUG: implementation details. -Rheo polyfills the `target()` function for EPUB compilation, so you can use standard Typst patterns: +## Release -**Basic usage (recommended):** +1. Update version in `Cargo.toml` +2. PR title = version tag (e.g. `v0.2.0`) + `release` label +3. Merge triggers automated build, crates.io publish, GitHub Release -```typst -// target() returns "epub" for EPUB, "html" for HTML, "paged" for PDF -#context if target() == "epub" { - [EPUB-specific content] -} else if target() == "html" { - [HTML-specific content] -} else { - [PDF content] -} -``` - -**Helper Functions (available via rheo.typ injection):** - -```typst -// Explicit helpers for format checking -#if is-rheo-epub() { [EPUB-only content] } -#if is-rheo-html() { [HTML-only content] } -#if is-rheo-pdf() { [PDF-only content] } -``` - -**How it works:** -- Rheo sets `sys.inputs.rheo-target` to "epub", "html", or "pdf" -- For EPUB compilation, a `target()` polyfill is injected that checks `sys.inputs.rheo-target` -- This shadows the built-in `target()` so `target() == "epub"` works naturally -- The polyfill is syntactic sugar for user code convenience - -**For Typst library/package authors:** - -The `target()` polyfill only shadows the local function name. Packages that call `std.target()` (common practice to get the "real" target) will bypass the polyfill and see "html" for EPUB compilation. +--- -To properly support rheo's EPUB detection, library authors should check `sys.inputs.rheo-target` directly: +## Version Control (jj — NEVER use git) -```typst -// Recommended pattern for libraries -#let get-format() = { - if "rheo-target" in sys.inputs { - sys.inputs.rheo-target // "epub", "html", or "pdf" when compiled with rheo - } else { - target() // Fallback for vanilla Typst - } -} +```bash +jj status / jj diff / jj log / jj show +jj commit -m "message" / jj describe -m "message" +jj new / jj new main / jj edit / jj abandon +jj squash / jj split / jj restore +jj git fetch / jj rebase -d main +jj git push -c @- / jj git push --allow-new ``` -This pattern: -- Returns the correct format when compiled with rheo -- Gracefully degrades to standard `target()` in vanilla Typst -- Works regardless of whether the package calls `target()` or `std.target()` - -### Incremental Compilation - -**Overview:** -Rheo's watch mode uses incremental compilation to achieve 3x-100x faster recompilation speeds compared to cold compilation. This is powered by Typst's comemo (constrained memoization) system. - -**How It Works:** -1. **World Reuse**: A single `RheoWorld` instance is created at watch startup and reused across all recompilations -2. **Cache Reset**: Before each recompilation, `world.reset()` clears file caches while preserving fonts, library, and packages -3. **Main File Switching**: `world.set_main()` updates which file is being compiled without recreating the World -4. **Comemo Caching**: Typst's memoization system caches compilation results and only recomputes changed parts -5. **Memory Management**: `comemo::evict(10)` after each compilation prevents unbounded cache growth - -**Architecture:** -- `compile_pdf()` / `compile_html()` - Regular functions for single compilation (compile command) -- `compile_pdf_incremental()` / `compile_html_incremental()` - Optimized functions that accept existing World (watch mode) -- `perform_compilation()` - Used by compile command (creates fresh World per file) -- `perform_compilation_incremental()` - Used by watch mode (reuses World across files) - -**Testing Incremental Compilation:** +**PR workflow:** ```bash -# Start watch mode - directory -cargo run -- watch examples/blog_site --html - -# Start watch mode - single file -cargo run -- watch examples/blog_site/content/index.typ --html - -# In another terminal, make a small edit to a file -echo "\n// Test change" >> examples/blog_site/content/index.typ - -# Observe recompilation time in watch output -# Initial compilation: ~2-3 seconds for all files -# Incremental recompilation: ~100-500ms for changed file +jj bookmark create feat/ -r @- +jj git push --allow-new +gh pr create --base main --head feat/ --title "..." --body "- bullet\n- bullet" ``` -**Performance Characteristics:** -- **Cold compilation** (first run or after config change): Full compilation of all files -- **Incremental compilation** (file edit in watch mode): Only recompiles changed files with cached dependencies -- **Memory usage**: Stabilizes due to `comemo::evict(10)` after each compilation -- **Cache invalidation**: Automatic based on file content changes - -**Key Implementation Files:** -- `src/rs/world.rs` - `RheoWorld::reset()` and `RheoWorld::set_main()` methods -- `src/rs/compile.rs` - `compile_*_incremental()` functions -- `src/rs/cli.rs` - Watch loop with World creation and reuse (lines 631-692) -- `Cargo.toml` - `comemo = "0.5"` dependency for cache management - -### Development Server and Live Reload - -**Overview:** -Rheo includes a built-in development server with automatic browser refresh for HTML output. The server is activated with the `--open` flag in watch mode, providing a seamless development experience. - -**How It Works:** -1. **Server Activation**: Use `--open` flag with watch command to start the server -2. **HTTP Server**: Runs on `http://localhost:3000` (port hardcoded, not configurable) -3. **SSE Endpoint**: Server-Sent Events endpoint at `/events` for browser communication -4. **Live Reload Script**: HTML files automatically include a script that connects to the SSE endpoint -5. **File Change Detection**: When Typst files change and recompile, server broadcasts reload events -6. **Browser Auto-Refresh**: Connected browsers receive the reload event and refresh automatically - -**Architecture:** -- `src/rs/server.rs` - Development server implementation using axum -- Port 3000 is hardcoded (see `cli.rs` line 598) -- SSE-based communication for zero-configuration live reload -- Serves static HTML files from the build/html directory -- Broadcast channel pattern for one-to-many client notifications - -**Usage:** -```bash -# Start watch mode with development server -cargo run -- watch examples/blog_site --open +**Commit messages:** Present tense, user-focused. "Displays X in Y", not "Added X" or "Add X". -# Server starts at http://localhost:3000 -# Browser opens automatically showing index.html -# Edit any .typ file - browser refreshes automatically -``` +**PR body:** 3-5 concise bullets. No "This PR", no LLM-style verbosity. -**Key Features:** -- Zero-configuration setup -- Automatic browser opening -- Instant refresh on file changes -- Works with incremental compilation for fast iteration -- Supports multiple connected browsers simultaneously - -**Implementation Details:** -- Server state includes broadcast channel and HTML directory path -- Static file handler serves .html files from build directory -- SSE handler streams reload events to connected clients -- Server runs in background task, doesn't block watch loop - -### Error Formatting and Logging - -**Overview:** -Rheo uses codespan-reporting for rich, user-friendly error and warning messages. Errors from Typst compilation are displayed with source context, line numbers, and color highlighting. - -**Error Output Format:** -- **Colored output**: Automatically enabled when stderr is a TTY -- **Source context**: Shows relevant code lines with error markers -- **Line numbers**: Displays using `│` box-drawing characters -- **Multi-error aggregation**: All errors reported before failing - -**Logging Levels:** -- **Normal mode** (default): Shows user-friendly INFO-level messages - - Project loading, compilation progress, success/failure - - Example: ` INFO compiling to PDF input=portable_epubs.typ` -- **Verbose mode** (`-v`): Shows DEBUG-level implementation details - - Build directory resolution, config loading, asset copying - - Example: ` DEBUG build directory dir=./build` -- **Quiet mode** (`-q`): Only shows errors - -**Log Message Guidelines:** -- INFO logs use natural language, not technical jargon -- Avoid timestamps (removed for cleaner output) -- No function names in user-facing logs (use spans for debugging) -- Implementation details go to DEBUG level - -**Example Error Output:** -``` -error: cannot add integer and string - ┌─ type_error.typ:10:15 - │ -10 │ let result = x + y - │ ^^^^^ -``` +--- -**Example Warning Output:** -``` -warning: block may not occur inside of a paragraph and was ignored - ┌─ portable_epubs.typ:21:7 - │ -21 │ block(body) - │ ^^^^^^^^^^^ -``` +## Issue Tracking (beads/bd — NEVER use markdown TODOs) -**Testing Error Formatting:** ```bash -# Run error formatting tests -cargo test test_error_formatting -- --nocapture - -# Run warning formatting tests -cargo test test_warning_formatting -- --nocapture +bd ready --json # find unblocked work +bd list --status=open +bd show +bd create "Title" -t bug|feature|task -p 0-4 --json +bd update --status in_progress --json +bd close --reason "Done" --json +bd dep add ``` -**Implementation Files:** -- `src/rs/formats/common.rs` - `print_diagnostics()` using codespan-reporting -- `src/rs/world.rs` - `Files` trait implementation for RheoWorld -- `src/rs/logging.rs` - Logging configuration (no timestamps, clean output) -- `tests/cases/error_formatting/` - Test files with intentional errors - -### Project-Specific Conventions - -**Commit Messages:** -- Follow the jj commit message guidelines (present tense, user-focused) -- Examples: "Compiles Typst to PDF and HTML", "Injects rheo.typ automatically" - -**Code Style:** -- Use `cargo fmt` before committing -- Fix all clippy warnings: `cargo clippy` -- Errors use thiserror for consistent error handling -- Logging uses tracing macros (info!, warn!, error!) - -**Dependencies:** -- Typst libraries are pulled from git main branch -- Keep dependencies minimal and well-justified - -### Branching and Release Workflow - -**Development Model:** -- All development happens via pull requests to `main` -- The `main` branch is the primary development branch -- No long-lived feature branches; PRs are merged directly to main - -**Release Process:** - -When ready to cut a new release: - -1. **Update version in Cargo.toml** to the new version number +**Priorities:** 0=critical, 1=high, 2=medium, 3=low, 4=backlog -2. **Create a release PR:** - - PR title MUST be the version tag (e.g., `v0.2.0`) - - Add the `release` label to the PR - -3. **Pre-release validation** (automated via `.github/workflows/pre-release.yml`): - - Builds and tests on all supported platforms (Linux x86_64/ARM, macOS x86_64/ARM, Windows x86_64/ARM) - - Validates PR title matches `vX.Y.Z` format - - Runs `cargo publish --dry-run` to verify crates.io readiness - -4. **Merge the PR** - triggers release automation (`.github/workflows/release.yml`): - - Builds release binaries for all platforms - - Publishes to crates.io - - Creates a git tag matching the PR title - - Creates a GitHub Release with platform-specific zip files - - Auto-generates release notes from merged PR titles since the last release (no manual changelog needed) - -**Supported Platforms:** -- `x86_64-unknown-linux-gnu` (Linux x86_64) -- `aarch64-unknown-linux-gnu` (Linux ARM64) -- `x86_64-apple-darwin` (macOS Intel) -- `aarch64-apple-darwin` (macOS Apple Silicon) -- `x86_64-pc-windows-msvc` (Windows x86_64) -- `aarch64-pc-windows-msvc` (Windows ARM64) - -**Release Artifacts:** -Each release includes zip files for each platform containing the `rheo` binary, available on the GitHub Releases page. Zip files are named `rheo-{target}.zip` for compatibility with `cargo binstall rheo`. +**Local-only:** `.beads/` is gitignored, never commit it, never run `bd sync`. --- -## Version Control with Jujutsu - -**IMPORTANT**: This project uses jj (Jujutsu) exclusively. NEVER use git commands. - -### Basic jj Commands -- `jj status` - Show current changes and working copy status -- `jj commit -m "message"` - Commit current changes with a message -- `jj describe -m "message"` - Set/update description of current change -- `jj log` - View commit history (graphical view) -- `jj diff` - Show diff of current changes -- `jj show` - Show details of current commit - -### Branch Management -- `jj new` - Create new change (equivalent to git checkout -b) -- `jj new main` - Create new change based on main -- `jj edit ` - Switch to editing a specific commit -- `jj abandon` - Abandon current change - -### Synchronization and Pull Requests -- `jj git fetch` - Fetch changes from remote repository -- `jj rebase -d main` - Rebase current change onto main -- `jj git push -c @-` - Push current change and create bookmark (@- refers to the parent of the current change, as the current change is generally empty) - -**Pull Request Workflow:** - -When you're ready to create a PR from your completed work: - -1. **Create bookmark from commit message** - Automatically derive bookmark name from `@-` commit: - ```bash - # Example: "Supports single-file compilation" → feat/supports-single-file-compilation - jj bookmark create feat/ -r @- - ``` - -2. **Push to GitHub**: - ```bash - jj git push --allow-new - ``` - -3. **Create PR with gh CLI**: - ```bash - gh pr create --base main --head feat/ \ - --title "" \ - --body "- Bullet point 1 - - Bullet point 2 - - Bullet point 3" - ``` - -**Bookmark naming:** -- Prefix: `feat/` for features, `fix/` for bug fixes -- Name: Convert commit message to kebab-case (lowercase, spaces → hyphens) -- Example: "Fixes compilation error" → `fix/fixes-compilation-error` - -**PR message format:** -- Title: Use commit message as-is (present tense) -- Body: 3-5 concise bullet points summarizing changes -- Each bullet: verb + what changed (Adds, Updates, Fixes, Implements, etc.) - -**After review:** -- `jj git fetch && jj rebase -d main` if needed - -**Pull Request Message Guidelines:** -- Keep descriptions concise and technical, avoid LLM-style verbosity -- Focus on what was changed, not implementation details -- Use bullet points for multiple changes -- Avoid phrases like "This PR", "I have implemented", or overly formal language -- Example: "Add while loops to parser and codegen" rather than "This pull request implements comprehensive while loop support across the compiler pipeline" - -### Commit Message Guidelines -- Write in imperative mood and present tense -- Be descriptive about what the change accomplishes -- Examples: "Add while loop support to parser", "Fix segfault in code generation", "Add assignment operators to the language" - -### Advanced Operations -- `jj split` - Split current change into multiple commits -- `jj squash` - Squash changes into parent commit -- `jj duplicate` - Create duplicate of current change -- `jj restore ` - Restore file from parent commit - ---- - -## Issue Tracking with Beads - -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +## The bd/jj Workflow (ALWAYS use for bd tasks) -### Why bd? - -- Dependency-aware: Track blockers and relationships between issues -- Agent-optimized: JSON output, ready work detection, discovered-from links -- Prevents duplicate tracking systems and confusion -- Local-only: Issues are stored locally, not shared via version control - -### Quick Start - -**Check for ready work:** +**Session prerequisite** — verify jj identity: ```bash -bd ready --json +jj config list --user +# If missing: +jj config set --user user.name "Lachlan Kermode" +jj config set --user user.email "lachie@ohrg.org" ``` -**Create new issues:** -```bash -bd create "Issue title" -t bug|feature|task -p 0-4 --json -bd create "Issue title" -p 1 --deps discovered-from:bd-123 --json -``` +**Per-task sequence:** +1. `bd update --status in_progress` +2. `jj log` — if empty unnamed commit below working commit, name it: `jj describe -m "..."` +3. `jj new` — fresh working commit +4. Do the work, run tests +5. `jj squash` then `jj describe -r @- -m "Present tense description"` +6. `jj log` — verify history shows correct author on each commit (not empty/unknown) +7. `bd close --reason "Done"` -**Claim and update:** -```bash -bd update bd-42 --status in_progress --json -bd update bd-42 --priority 1 --json -``` +--- -**Complete work:** +## bd/jj Churn (only when user says "bd/jj churn") + +**Before first loop iteration** — verify jj identity (commits without author are broken): ```bash -bd close bd-42 --reason "Completed" --json +jj config list --user +# Must show user.name and user.email. If missing: +jj config set --user user.name "Lachlan Kermode" +jj config set --user user.email "lachie@ohrg.org" ``` -### Issue Types - -- `bug` - Something broken -- `feature` - New functionality -- `task` - Work item (tests, docs, refactoring) -- `epic` - Large feature with subtasks -- `chore` - Maintenance (dependencies, tooling) - -### Priorities - -- `0` - Critical (security, data loss, broken builds) -- `1` - High (major features, important bugs) -- `2` - Medium (default, nice-to-have) -- `3` - Low (polish, optimization) -- `4` - Backlog (future ideas) - -### Workflow for AI Agents - -1. **Check ready work**: `bd ready` shows unblocked issues -2. **Claim your task**: `bd update --status in_progress` -3. **Work on it**: Implement, test, document -4. **Discover new work?** Create linked issue: - - `bd create "Found bug" -p 1 --deps discovered-from:` -5. **Complete**: `bd close --reason "Done"` - -### Local-Only Configuration - -**IMPORTANT**: This project uses beads as a **local implementation detail only**. The `.beads/` directory is gitignored and NOT shared via version control. - -Configuration (`.beads/config.yaml`): -- `no-auto-flush: true` - Disables automatic JSONL export (since not tracked in git) -- `no-auto-import: true` - Disables automatic JSONL import (since not tracked in git) -- Issues are stored in the local SQLite database only - -This means: -- ✅ Use bd for local task tracking and workflow management -- ✅ Issues are private to your local checkout -- ❌ Do NOT commit `.beads/` files to version control -- ❌ Issues are NOT shared between team members or machines - -### MCP Server (Recommended) - -If using Claude or MCP-compatible clients, install the beads MCP server: +Loop until no open issues: +1. `bd ready --json` — pick highest priority (bugs/tasks/features, not epics/chores) +2. Implement with bd/jj workflow +3. `/clear` — clear context +4. Repeat +When done: ```bash -pip install beads-mcp +cargo fmt +cargo clippy --fix --all-targets --all-features --allow-dirty -- -D warnings +# jj squash if changes made ``` -Add to MCP config (e.g., `~/.config/claude/config.json`): -```json -{ - "beads": { - "command": "beads-mcp", - "args": [] - } -} -``` - -Then use `mcp__beads__*` functions instead of CLI commands. - -### Important Rules - -- ✅ Use bd for ALL task tracking -- ✅ Always use `--json` flag for programmatic use -- ✅ Link discovered work with `discovered-from` dependencies -- ✅ Check `bd ready` before asking "what should I work on?" -- ❌ Do NOT create markdown TODO lists -- ❌ Do NOT use external issue trackers -- ❌ Do NOT duplicate tracking systems -- ❌ Do NOT run `bd sync` — beads is local-only in this project, there is nothing to sync +Report: list all closed issues. --- -## The bd/jj workflow - -**IMPORTANT**: ALWAYS use the jj squash workflow when working on bd tasks, even if you're only implementing a single task. This workflow should be your default approach. - -When working through bd (beads) tasks, use the jj squash workflow. This creates a clean commit history where related work is grouped together. - -### The Squash Pattern - -The workflow maintains two commits: -- **Named commit** (bottom): Empty at first, receives work via squash. Has a descriptive message. -- **Working commit** (top): Unnamed and empty. All changes happen here, then get squashed down. - -After squashing, the working commit becomes empty again, and the pattern repeats. - -### Per-Task Workflow - -For each bd task, follow this sequence: - -1. **Name the commit**: Run `jj describe -m "Present tense description"` - - Message describes what the app does after this change - - Completes the phrase: "when this commit is applied, the app..." - - Examples: - - "Renders timeline using real-world data" - - "Improves coloration of navbar" - - "Adds date-based scroll mapping to timeline" - - Use present tense, NOT past tense or imperative mood - - Focus on user-visible changes, not implementation details - -2. **Create working commit**: Run `jj new` - - This creates a new empty commit on top where you'll do the work - - All file changes will go into this commit - -3. **Complete the bd task**: - - Implement the changes - - Test that it works - - Close the issue: `bd update --status closed` - -4. **Squash the work**: Run `jj squash` - - Moves all changes from the working commit (top) into the named commit (below) - - Working commit becomes empty again, ready for next task - -5. **Repeat**: Go to step 1 for the next task - -### Commit Message Examples - -✅ Good (present tense, user-focused): -- "Displays flight hours in timeline visualization" -- "Renders year markers in timeline sidebar" -- "Synchronizes timeline scroll with table position" -- "Shows data gaps as empty bars in timeline" - -❌ Bad (wrong tense or too technical): -- "Added TimelineBar component" (past tense) -- "Add timeline visualization" (imperative, not present) -- "Refactors VerticalTimeline.jsx to use new components" (implementation detail) -- "Created data utilities module" (past tense, not user-visible) - -### When to Use This Workflow +## Plan Mode (activated by "plan mode", "let's plan", "design this", or any prompt ending with "BEADS") -**ALWAYS use this workflow** when working on bd tasks. This is the standard approach for this project. +**Rules:** No code, no file edits (except `.beads/`). Output is beads issues only. -The workflow works for: -- Single bd tasks (one task = one commit) -- Multiple related bd tasks (multiple tasks = one commit) -- Any feature or bug fix tracked in bd +**Workflow:** +1. Understand goal, ask clarifying questions +2. Decompose into discrete bd issues with type, priority, acceptance criteria +3. Present proposal to user, ask if they want to create the issues +4. If yes: run `bd create` commands (parallel where possible), set up deps with `bd dep add` + - Each issue's `--description` must be procedural and unambiguous — written as if for an agent with no prior context. Include: background, relevant file paths and line numbers, exact steps to implement, and the expected outcome. The implementer must not need to investigate or infer anything. +5. List created IDs and stop — do NOT implement, do NOT ask if user wants to implement -Only skip this workflow when: -- User explicitly requests a different approach -- Working on unrelated changes that must be separate commits +**Exits** when user says "bd/jj churn", "start implementing", or "go". diff --git a/Cargo.lock b/Cargo.lock index a3145cf..3a1038b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -95,12 +106,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - [[package]] name = "approx" version = "0.5.1" @@ -289,6 +294,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -336,6 +350,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.24.0" @@ -368,6 +388,15 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.47" @@ -458,6 +487,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "citationberg" version = "0.6.1" @@ -1734,6 +1773,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "iref" version = "3.2.2" @@ -1999,10 +2048,12 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lopdf" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +checksum = "5c7c1d3350d071cb86987a6bcb205c7019a0eb70dcad92b454fec722cca8d68b" dependencies = [ + "aes", + "cbc", "chrono", "encoding_rs", "flate2", @@ -2011,8 +2062,10 @@ dependencies = [ "log", "md-5", "nom", + "nom_locate", "rangemap", "rayon", + "thiserror 2.0.17", "time", "weezl", ] @@ -2185,6 +2238,17 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + [[package]] name = "normpath" version = "1.5.0" @@ -2220,9 +2284,9 @@ checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" [[package]] name = "ntest" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb183f0a1da7a937f672e5ee7b7edb727bf52b8a52d531374ba8ebb9345c0330" +checksum = "54d1aa56874c2152c24681ed0df95ee155cc06c5c61b78e2d1e8c0cae8bc5326" dependencies = [ "ntest_test_cases", "ntest_timeout", @@ -2230,9 +2294,9 @@ dependencies = [ [[package]] name = "ntest_test_cases" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d0d3f2a488592e5368ebbe996e7f1d44aa13156efad201f5b4d84e150eaa93" +checksum = "6913433c6319ef9b2df316bb8e3db864a41724c2bb8f12555e07dc4ec69d3db1" dependencies = [ "proc-macro2", "quote", @@ -2241,9 +2305,9 @@ dependencies = [ [[package]] name = "ntest_timeout" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc7c92f190c97f79b4a332f5e81dcf68c8420af2045c936c9be0bc9de6f63b5" +checksum = "9224be3459a0c1d6e9b0f42ab0e76e98b29aef5aba33c0487dfcf47ea08b5150" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2658,7 +2722,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -2789,9 +2853,9 @@ checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab" [[package]] name = "rangemap" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbbbbea733ec66275512d0b9694f34102e7d5406fdbe2ad8d21b28dce92887c" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "rayon" @@ -2899,45 +2963,60 @@ dependencies = [ ] [[package]] -name = "rheo" -version = "0.1.2" +name = "rheo-cli" +version = "0.2.0" dependencies = [ - "anyhow", "atty", "axum", "chrono", "clap", + "glob", + "globset", + "mime_guess", + "notify", + "opener", + "parking_lot", + "pathdiff", + "rheo-core", + "rheo-epub", + "rheo-html", + "rheo-pdf", + "semver", + "serde", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "toml 0.9.10+spec-1.1.0", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "walkdir", + "webbrowser", +] + +[[package]] +name = "rheo-core" +version = "0.2.0" +dependencies = [ + "atty", + "chrono", "codespan-reporting", "comemo", "ecow", "glob", "globset", - "html5ever", - "iref", - "itertools", "lazy_static", - "lopdf", - "markup5ever_rcdom", - "mime_guess", "notify", - "ntest", "opener", "parking_lot", "pathdiff", "regex", "semver", "serde", - "serde-xml-rs", - "serde_json", - "sha2", - "similar", "tempfile", "thiserror 2.0.17", - "tokio", - "tokio-stream", "toml 0.9.10+spec-1.1.0", - "tower", - "tower-http", "tracing", "tracing-subscriber", "typst", @@ -2946,9 +3025,76 @@ dependencies = [ "typst-library", "typst-pdf", "typst-syntax", - "uuid", "walkdir", +] + +[[package]] +name = "rheo-epub" +version = "0.2.0" +dependencies = [ + "chrono", + "html5ever", + "iref", + "itertools", + "markup5ever_rcdom", + "rheo-core", + "serde", + "serde-xml-rs", + "tempfile", + "thiserror 2.0.17", + "toml 0.9.10+spec-1.1.0", + "tracing", + "uuid", + "zip", +] + +[[package]] +name = "rheo-html" +version = "0.2.0" +dependencies = [ + "axum", + "html5ever", + "markup5ever_rcdom", + "mime_guess", + "rheo-core", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tower", + "tower-http", + "tracing", "webbrowser", +] + +[[package]] +name = "rheo-pdf" +version = "0.2.0" +dependencies = [ + "rheo-core", + "tempfile", + "thiserror 2.0.17", + "tracing", +] + +[[package]] +name = "rheo-tests" +version = "0.2.0" +dependencies = [ + "html5ever", + "lopdf", + "markup5ever_rcdom", + "ntest", + "rheo-core", + "rheo-epub", + "rheo-html", + "rheo-pdf", + "serde", + "serde-xml-rs", + "serde_json", + "sha2", + "similar", + "tempfile", + "walkdir", "zip", ] @@ -3782,9 +3928,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "toml_datetime 0.7.5+spec-1.1.0", @@ -5043,9 +5189,9 @@ dependencies = [ [[package]] name = "xml" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df5825faced2427b2da74d9100f1e2e93c533fff063506a81ede1cf517b2e7e" +checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" [[package]] name = "xml5ever" diff --git a/Cargo.toml b/Cargo.toml index b8e96a8..dd08e39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,34 +1,24 @@ -[package] -name = "rheo" -version = "0.1.2" +[workspace] +resolver = "2" +members = ["crates/core", "crates/html", "crates/pdf", "crates/epub", "crates/cli", "crates/tests"] + +[workspace.package] +version = "0.2.0" edition = "2024" authors = ["Lachlan Kermode "] description = "A typesetting and static site engine based on Typst" license = "MIT OR Apache-2.0" repository = "https://github.com/freecomputinglab/rheo" readme = "README.md" -exclude = [ - "tests/", - "examples/", - ".github/", - ".beads/", - ".claude/", - "flake.nix", - "flake.lock", - ".envrc", - "CLAUDE.md", - "Justfile", -] - -[[bin]] -name = "rheo" -path = "src/rs/main.rs" -[lib] -name = "rheo" -path = "src/rs/lib.rs" +[workspace.dependencies] +# Internal crates (for convenience in workspace) +rheo-core = { path = "crates/core" } +rheo-html = { path = "crates/html" } +rheo-pdf = { path = "crates/pdf" } +rheo-epub = { path = "crates/epub" } -[dependencies] +# Typst dependencies typst = "0.14.2" typst-library = "0.14.2" typst-pdf = "0.14.2" @@ -36,50 +26,60 @@ typst-html = "0.14.2" typst-kit = "0.14.2" typst-syntax = "0.14.2" comemo = "0.5" + +# Error handling and diagnostics codespan-reporting = "0.13" -ecow = "0.2" +thiserror = "2.0" +anyhow = "1.0.100" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +toml = "0.9" +semver = "1.0" + +# CLI and utilities clap = { version = "4.5", features = ["derive"] } walkdir = "2.5" -thiserror = "2.0" opener = "0.8" parking_lot = "0.12" -chrono = { version = "0.4", features = ["serde"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] } atty = "0.2" pathdiff = "0.2" -toml = "0.9" -serde = { version = "1.0", features = ["derive"] } -semver = "1.0" +notify = "8.2" +mime_guess = "2.0" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] } + +# Date/time +chrono = { version = "0.4", features = ["serde"] } + +# File system and patterns globset = "0.4" glob = "0.3" -notify = "8.2" +tempfile = "3.8" + +# Development server (HTML plugin uses, CLI also uses) tokio = { version = "1", features = ["rt", "sync", "time", "signal", "rt-multi-thread", "macros"] } axum = "0.8" tower = "0.5" tower-http = { version = "0.6", features = ["fs", "trace"] } webbrowser = "1.0" -mime_guess = "2.0" tokio-stream = { version = "0.1", features = ["sync"] } -regex = "1.10" -lazy_static = "1.4" -serde-xml-rs = "0.8.2" + +# EPUB dependencies zip = { version = "6.0.0", default-features = false } html5ever = "0.36.1" markup5ever_rcdom = "0.36.0" -anyhow = "1.0.100" iref = { version = "3.2.2", features = ["serde"] } -itertools = "0.14.0" uuid = { version = "1.18.1", features = ["v4"] } -tempfile = "3.8" +itertools = "0.14.0" -[dev-dependencies] -serde_json = "1.0" -similar = "2.5" -glob = "0.3" -lopdf = "0.34" -ntest = "0.9.3" -sha2 = "0.10" +# Other dependencies +ecow = "0.2" +regex = "1.10" +lazy_static = "1.4" +serde-xml-rs = "0.8.2" [profile.release] opt-level = 1 diff --git a/Justfile b/Justfile index 54f0e89..3e47a5d 100644 --- a/Justfile +++ b/Justfile @@ -7,17 +7,9 @@ test: build: cargo build -update-submodules: - git submodule update --remote --merge - git add examples/fcl_site examples/rheo_docs - git commit -m "Updates git submodules to latest" - jj git import - git submodule status - @echo "" - @echo "Submodule references updated and committed. Ready for 'jj git push'." install: - cargo install --path . --locked + cargo install --path crates/rheo --locked watch: cargo watch -x "build --profile local-dev" \ No newline at end of file diff --git a/README.md b/README.md index a59491c..24cb3cb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ See [the documentation](https://rheo.ohrg.org) for more information regarding wh cargo binstall rheo ``` +**Note:** The `rheo` package is a lightweight alias that re-exports `rheo-cli`. For direct installation, you can also use `cargo binstall rheo-cli`. + ### Using cargo Rheo requires Rust and Cargo. Install from [rustup.rs](https://rustup.rs/). diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..b1233af --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "rheo-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "rheo" +path = "src/main.rs" + +[dependencies] +# Internal crates +rheo-core = { path = "../core", version = "0.2.0" } +rheo-html = { path = "../html", version = "0.2.0" } +rheo-pdf = { path = "../pdf", version = "0.2.0" } +rheo-epub = { path = "../epub", version = "0.2.0" } + +# CLI and utilities +clap = { workspace = true } +walkdir = { workspace = true } +opener = { workspace = true } +parking_lot = { workspace = true } +atty = { workspace = true } +pathdiff = { workspace = true } +notify = { workspace = true } +mime_guess = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Date/time +chrono = { workspace = true } + +# Serialization +serde = { workspace = true } +toml = { workspace = true } +semver = { workspace = true } + +# Development server +tokio = { workspace = true } +axum = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +webbrowser = { workspace = true } +tokio-stream = { workspace = true } + +# Error handling +thiserror = { workspace = true } + +# Patterns +globset = { workspace = true } +glob = { workspace = true } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 0000000..dd4646d --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,835 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; +use rheo_core::OpenHandle; +use rheo_core::compile::RheoCompileOptions; +use rheo_core::config::PluginSection; +use rheo_core::manifest_version; +use rheo_core::output::OutputConfig; +use rheo_core::project::ProjectConfig; +use rheo_core::results::CompilationResults; +use rheo_core::watch::{WatchEvent, watch_project}; +use rheo_core::world::RheoWorld; +use rheo_core::{FormatPlugin, PluginContext, Result, RheoError, SpineOptions}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, error, info, warn}; + +// Re-export logging functionality +pub use rheo_core::logging; + +/// Initialize logging with specified verbosity +pub fn init_logging(verbose: bool, quiet: bool) -> Result<()> { + let verbosity = if quiet { + logging::Verbosity::Quiet + } else if verbose { + logging::Verbosity::Verbose + } else { + logging::Verbosity::Normal + }; + logging::init(verbosity) +} + +/// Returns all known format plugins. Adding a new plugin here is the only +/// change needed in `cli` to support a new output format. +fn all_plugins() -> Vec> { + vec![ + Box::new(rheo_html::HtmlPlugin), + Box::new(rheo_pdf::PdfPlugin), + Box::new(rheo_epub::EpubPlugin), + ] +} + +/// Build the top-level clap `Command`, adding per-plugin `--` flags +/// dynamically to `compile` and `watch` subcommands. +fn build_cli() -> Command { + let plugins = all_plugins(); + Command::new("rheo") + .about("A tool for flowing Typst documents into publishable outputs") + .version(env!("CARGO_PKG_VERSION")) + .arg( + Arg::new("quiet") + .short('q') + .long("quiet") + .action(ArgAction::SetTrue) + .conflicts_with("verbose") + .global(true) + .help("Decrease output verbosity (errors only)"), + ) + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .action(ArgAction::SetTrue) + .conflicts_with("quiet") + .global(true) + .help("Increase output verbosity (show debug information)"), + ) + .subcommand(build_compile_command(&plugins)) + .subcommand(build_watch_command(&plugins)) + .subcommand(build_clean_command()) + .subcommand(build_init_command()) + .subcommand_required(true) + .arg_required_else_help(true) +} + +fn add_format_flags(mut cmd: Command, plugins: &[Box]) -> Command { + for plugin in plugins { + cmd = cmd.arg( + Arg::new(plugin.name()) + .long(plugin.name()) + .action(ArgAction::SetTrue) + .help(format!("Compile to {} only", plugin.name())), + ); + } + cmd +} + +fn build_compile_command(plugins: &[Box]) -> Command { + let cmd = Command::new("compile") + .about("Compile Typst documents to PDF, HTML, and/or EPUB") + .arg( + Arg::new("path") + .required(true) + .index(1) + .help("Path to project directory or single .typ file"), + ) + .arg( + Arg::new("config") + .long("config") + .value_name("PATH") + .help("Path to custom rheo.toml config file"), + ) + .arg( + Arg::new("build-dir") + .long("build-dir") + .help("Build output directory (overrides rheo.toml if set)"), + ); + add_format_flags(cmd, plugins) +} + +fn build_watch_command(plugins: &[Box]) -> Command { + let cmd = Command::new("watch") + .about("Watch Typst documents and recompile on changes") + .arg( + Arg::new("path") + .required(true) + .index(1) + .help("Path to project directory or single .typ file"), + ) + .arg( + Arg::new("config") + .long("config") + .value_name("PATH") + .help("Path to custom rheo.toml config file"), + ) + .arg( + Arg::new("build-dir") + .long("build-dir") + .help("Build output directory (overrides rheo.toml if set)"), + ) + .arg( + Arg::new("open") + .long("open") + .action(ArgAction::SetTrue) + .help("Open output in appropriate viewer (HTML opens in browser with live reload)"), + ); + add_format_flags(cmd, plugins) +} + +fn build_clean_command() -> Command { + Command::new("clean") + .about("Clean build artifacts for a project") + .arg( + Arg::new("path") + .index(1) + .default_value(".") + .help("Path to project directory or single .typ file"), + ) + .arg( + Arg::new("config") + .long("config") + .value_name("PATH") + .help("Path to custom rheo.toml config file"), + ) + .arg( + Arg::new("build-dir") + .long("build-dir") + .help("Build output directory to clean (overrides rheo.toml if set)"), + ) +} + +fn build_init_command() -> Command { + Command::new("init") + .about("Initialize a new Rheo project") + .arg( + Arg::new("path") + .required(true) + .index(1) + .help("Path to the new project directory"), + ) +} + +/// Extract enabled format names from arg matches (names of plugins whose flags are set). +fn enabled_formats_from_matches( + matches: &ArgMatches, + plugins: &[Box], +) -> Vec { + plugins + .iter() + .filter(|p| matches.get_flag(p.name())) + .map(|p| p.name().to_string()) + .collect() +} + +/// Determine which format names to compile based on CLI flags and config defaults. +/// +/// Priority: +/// 1. CLI flags (any set → use only those) +/// 2. Config `formats` list (non-empty → use that) +/// 3. All plugins (fallback) +fn determine_formats( + enabled_from_cli: Vec, + config_defaults: &[String], + all: &[Box], +) -> Vec { + if !enabled_from_cli.is_empty() { + return enabled_from_cli; + } + if !config_defaults.is_empty() { + return config_defaults.to_vec(); + } + all.iter().map(|p| p.name().to_string()).collect() +} + +/// Filter `all_plugins()` to only those whose names appear in `formats`. +fn plugins_for_formats( + formats: &[String], + all: Vec>, +) -> Vec> { + all.into_iter() + .filter(|p| formats.iter().any(|f| f == p.name())) + .collect() +} + +/// Pre-compiled setup context for compilation commands. +struct CompilationContext { + project: ProjectConfig, + plugins: Vec>, + output_config: OutputConfig, +} + +/// Resolve a path relative to a base directory. +fn resolve_path(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +/// Resolve build directory with priority: CLI arg > config > default. +fn resolve_build_dir( + project: &ProjectConfig, + cli_build_dir: Option, +) -> Result> { + if let Some(cli_path) = cli_build_dir { + let cwd = + std::env::current_dir().map_err(|e| RheoError::io(e, "getting current directory"))?; + debug!(dir = %cli_path.display(), "build directory"); + Ok(Some(resolve_path(&cwd, &cli_path))) + } else if let Some(config_path) = &project.config.build_dir { + let resolved = resolve_path(&project.root, Path::new(config_path)); + debug!(dir = %resolved.display(), "build directory"); + Ok(Some(resolved)) + } else { + Ok(None) + } +} + +fn get_output_filename(typ_file: &std::path::Path) -> Result { + typ_file + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .ok_or_else(|| RheoError::project_config(format!("invalid .typ filename: {:?}", typ_file))) +} + +fn get_files_for_plugin( + plugin: &dyn FormatPlugin, + project: &ProjectConfig, +) -> Result> { + match project.config.spine_for_plugin(plugin.name()) { + None => { + // No spine config: return all .typ files sorted lexicographically + let mut files = project.typ_files.clone(); + files.sort(); + Ok(files) + } + Some(spine) => { + // Spine config: return spine files in declared order + let content_dir = project + .config + .resolve_content_dir(&project.root) + .unwrap_or_else(|| project.root.clone()); + let spine_options = SpineOptions { + title: spine.title.clone(), + vertebrae: spine.vertebrae.clone(), + merge: spine.merge.unwrap_or(false), + }; + rheo_core::reticulate::spine::generate_spine(&content_dir, Some(&spine_options), false) + } + } +} + +/// Per-plugin invariants shared across all files in a single-plugin compilation pass. +struct PerFileCtx<'a> { + plugin: &'a dyn FormatPlugin, + plugin_output_dir: &'a Path, + project: &'a ProjectConfig, + output_config: &'a OutputConfig, + spine: &'a SpineOptions, + plugin_section: &'a PluginSection, + resolved_inputs: &'a HashMap<&'static str, PathBuf>, +} + +/// Compile one file with the given world, recording success/failure in `results`. +/// +/// `get_output_filename` errors propagate; `plugin.compile()` errors are recorded +/// as failures rather than propagated (so other files in the batch still compile). +fn compile_one_file( + world: &mut RheoWorld, + typ_file: &Path, + pfc: &PerFileCtx<'_>, + results: &mut CompilationResults, +) -> Result<()> { + let filename = get_output_filename(typ_file)?; + let output_path = pfc + .plugin_output_dir + .join(&filename) + .with_extension(pfc.plugin.name()); + let options = + RheoCompileOptions::new(Some(typ_file), &output_path, &pfc.project.root, Some(world)); + let ctx = PluginContext { + project: pfc.project, + output_config: pfc.output_config, + options, + spine: pfc.spine.clone(), + config: pfc.plugin_section.clone(), + inputs: pfc.resolved_inputs.clone(), + }; + match pfc.plugin.compile(ctx) { + Ok(_) => results.record_success(pfc.plugin.name()), + Err(e) => { + error!(file = %typ_file.display(), error = %e, "{} compilation failed", pfc.plugin.name()); + results.record_failure(pfc.plugin.name()); + } + } + Ok(()) +} + +fn perform_compilation( + project: &ProjectConfig, + output_config: &OutputConfig, + plugins: &[Box], + mut world: Option<&mut RheoWorld>, +) -> Result<()> { + if project.typ_files.is_empty() { + return Err(RheoError::project_config("no .typ files found in project")); + } + + let mut results = CompilationResults::new(); + + for plugin in plugins { + let plugin_output_dir = output_config.dir_for_plugin(plugin.name()); + std::fs::create_dir_all(&plugin_output_dir).map_err(|e| { + RheoError::io( + e, + format!("creating output directory for {}", plugin.name()), + ) + })?; + + // Resolve declared inputs + let mut resolved_inputs: HashMap<&'static str, PathBuf> = HashMap::new(); + for input in plugin.inputs() { + let src = project.root.join(&input.path); + if src.is_file() { + let dest = plugin_output_dir.join(&input.path); + std::fs::copy(&src, &dest).map_err(|e| { + RheoError::io( + e, + format!( + "copying plugin input '{}' from {} to {}", + input.name, + src.display(), + dest.display() + ), + ) + })?; + resolved_inputs.insert(input.name, dest); + } else if input.required { + return Err(RheoError::project_config(format!( + "plugin '{}' requires input '{}' at '{}' but it was not found", + plugin.name(), + input.name, + &input.path + ))); + } + } + + // Execute copy patterns (global + per-plugin) + let plugin_section_for_copy = project.config.plugin_section(plugin.name()); + for pattern in project + .config + .copy + .iter() + .chain(plugin_section_for_copy.copy.iter()) + { + let abs_pattern = project.root.join(pattern).display().to_string(); + let entries = glob::glob(&abs_pattern).map_err(|e| { + RheoError::project_config(format!("invalid copy pattern '{}': {}", pattern, e)) + })?; + let mut matched = false; + for entry in entries.filter_map(|e| e.ok()).filter(|p| p.is_file()) { + matched = true; + let rel = entry.strip_prefix(&project.root).unwrap_or(entry.as_path()); + let dest = plugin_output_dir.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + RheoError::io( + e, + format!("creating directory for copy of {}", rel.display()), + ) + })?; + } + std::fs::copy(&entry, &dest).map_err(|e| { + RheoError::io( + e, + format!("copying {} to {}", entry.display(), dest.display()), + ) + })?; + debug!(src = %entry.display(), dest = %dest.display(), "copied file"); + } + if !matched { + debug!(pattern = %pattern, "copy pattern matched no files"); + } + } + + // Resolve spine options + let spine_cfg = project.config.spine_for_plugin(plugin.name()); + let spine = SpineOptions { + title: spine_cfg.and_then(|s| s.title.clone()), + vertebrae: spine_cfg.map(|s| s.vertebrae.clone()).unwrap_or_default(), + merge: spine_cfg + .and_then(|s| s.merge) + .unwrap_or(plugin.default_merge()), + }; + + // Get full plugin section + let plugin_section = project.config.plugin_section(plugin.name()); + + if spine.merge { + let compilation_root = project + .config + .resolve_content_dir(&project.root) + .unwrap_or_else(|| project.root.clone()); + let output_path = plugin_output_dir + .join(&project.name) + .with_extension(plugin.name()); + + let options = + RheoCompileOptions::new(None::, &output_path, &compilation_root, None); + + let ctx = PluginContext { + project, + output_config, + options, + spine, + config: plugin_section, + inputs: resolved_inputs, + }; + + match plugin.compile(ctx) { + Ok(_) => { + results.record_success(plugin.name()); + } + Err(e) => { + error!(error = %e, "{} generation failed", plugin.name()); + results.record_failure(plugin.name()); + } + } + } else { + let files = get_files_for_plugin(plugin.as_ref(), project)?; + let pfc = PerFileCtx { + plugin: plugin.as_ref(), + plugin_output_dir: &plugin_output_dir, + project, + output_config, + spine: &spine, + plugin_section: &plugin_section, + resolved_inputs: &resolved_inputs, + }; + + if let Some(ref mut existing_world) = world { + for typ_file in &files { + existing_world.set_main(typ_file)?; + existing_world.reset(); + compile_one_file(existing_world, typ_file, &pfc, &mut results)?; + } + } else { + // Collect plugin library code to inject + let plugin_library = plugin.typst_library().map(|s| s.to_string()); + + for typ_file in &files { + let mut fresh_world = RheoWorld::new( + &project.root, + typ_file, + Some(plugin.name()), + plugin_library.clone(), + )?; + compile_one_file(&mut fresh_world, typ_file, &pfc, &mut results)?; + } + } + } + } + + let names: Vec<&str> = plugins.iter().map(|p| p.name()).collect(); + results.log_summary(&names); + + if results.has_failures() { + if names.iter().any(|name| results.get(name).succeeded > 0) { + Err(RheoError::project_config( + "some formats failed to compile".to_string(), + )) + } else { + Err(RheoError::project_config( + "all formats failed or no files were compiled".to_string(), + )) + } + } else { + info!("compilation complete"); + Ok(()) + } +} + +fn init_project(target_dir: &Path) -> Result<()> { + if target_dir.exists() { + return Err(RheoError::project_config(format!( + "directory '{}' already exists", + target_dir.display() + ))); + } + + fs::create_dir_all(target_dir).map_err(|e| RheoError::io(e, "creating target directory"))?; + + let toml_content = + rheo_core::init_templates::RHEO_TOML.replace("{{VERSION}}", manifest_version::CURRENT); + fs::write(target_dir.join("rheo.toml"), toml_content) + .map_err(|e| RheoError::io(e, "writing rheo.toml"))?; + + let content_dir = target_dir.join("content"); + fs::create_dir_all(&content_dir).map_err(|e| RheoError::io(e, "creating content directory"))?; + + fs::write( + content_dir.join("index.typ"), + rheo_core::init_templates::CONTENT_INDEX_TYP, + ) + .map_err(|e| RheoError::io(e, "writing index.typ"))?; + fs::write( + content_dir.join("about.typ"), + rheo_core::init_templates::CONTENT_ABOUT_TYP, + ) + .map_err(|e| RheoError::io(e, "writing about.typ"))?; + fs::write( + content_dir.join("references.bib"), + rheo_core::init_templates::CONTENT_REFERENCES_BIB, + ) + .map_err(|e| RheoError::io(e, "writing references.bib"))?; + + let img_dir = content_dir.join("img"); + fs::create_dir_all(&img_dir).map_err(|e| RheoError::io(e, "creating img directory"))?; + fs::write( + img_dir.join("header.svg"), + rheo_core::init_templates::CONTENT_IMG_HEADER_SVG, + ) + .map_err(|e| RheoError::io(e, "writing header.svg"))?; + + // Collect template contributions from all plugins + let mut plugin_templates: std::collections::HashMap<&str, (&str, &str)> = + std::collections::HashMap::new(); + for plugin in all_plugins() { + for (path, content) in plugin.init_templates() { + if let Some((existing_plugin, _)) = plugin_templates.get(path) { + return Err(RheoError::project_config(format!( + "template path conflict: both '{}' and '{}' plugins want to write '{}'", + existing_plugin, + plugin.name(), + path + ))); + } + plugin_templates.insert(path, (plugin.name(), content)); + } + } + + // Write plugin template files + for (path, (plugin_name, content)) in plugin_templates { + let file_path = target_dir.join(path); + if let Some(parent_dir) = file_path.parent() { + fs::create_dir_all(parent_dir) + .map_err(|e| RheoError::io(e, "creating plugin template directory"))?; + } + fs::write(&file_path, content) + .map_err(|e| RheoError::io(e, format!("writing plugin template file '{}'", path)))?; + debug!(plugin = plugin_name, path = %path, "wrote plugin template file"); + } + + info!(path = %target_dir.display(), "initialized rheo project"); + Ok(()) +} + +/// Setup: load project, apply smart defaults (if no config file), resolve plugins + build dir. +fn setup_compilation_context( + path: &Path, + config_path: Option<&Path>, + build_dir: Option, + enabled_from_cli: Vec, +) -> Result { + info!(path = %path.display(), "loading project"); + let mut project = ProjectConfig::from_path(path, config_path)?; + let file_word = if project.typ_files.len() == 1 { + "file" + } else { + "files" + }; + info!( + name = %project.name, + files = project.typ_files.len(), + "found {} Typst {}", + project.typ_files.len(), + file_word + ); + + let all = all_plugins(); + let formats = determine_formats(enabled_from_cli, &project.config.formats, &all); + + // Apply plugin smart defaults for all plugins + // Plugins check their own state and only fill in missing values + { + let plugins = plugins_for_formats(&formats, all_plugins()); + for plugin in &plugins { + let section = project + .config + .plugin_sections + .entry(plugin.name().to_string()) + .or_default(); + plugin.apply_defaults(section, &project.name); + } + } + + let plugins = plugins_for_formats(&formats, all); + + let resolved_build_dir = resolve_build_dir(&project, build_dir)?; + let output_config = OutputConfig::new(&project.root, resolved_build_dir); + + Ok(CompilationContext { + project, + plugins, + output_config, + }) +} + +/// Main entry point using the builder-based dynamic CLI. +pub fn run() -> Result<()> { + let cli = build_cli(); + let matches = cli.get_matches(); + + let quiet = matches.get_flag("quiet"); + let verbose = matches.get_flag("verbose"); + init_logging(verbose, quiet)?; + + match matches.subcommand() { + Some(("compile", sub)) => run_compile(sub), + Some(("watch", sub)) => run_watch(sub), + Some(("clean", sub)) => run_clean(sub), + Some(("init", sub)) => { + let path = PathBuf::from(sub.get_one::("path").unwrap()); + init_project(&path) + } + _ => unreachable!("subcommand_required enforced by clap"), + } +} + +fn run_watch(sub: &ArgMatches) -> Result<()> { + let path = PathBuf::from(sub.get_one::("path").unwrap()); + let config_path = sub.get_one::("config").map(PathBuf::from); + let build_dir = sub.get_one::("build-dir").map(PathBuf::from); + let open = sub.get_flag("open"); + + let all = all_plugins(); + let enabled = enabled_formats_from_matches(sub, &all); + + let mut ctx = setup_compilation_context( + &path, + config_path.as_deref(), + build_dir.clone(), + enabled.clone(), + )?; + + // Initial compilation (best-effort; watch continues on failure) + if let Err(e) = perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None) { + warn!(error = %e, "initial compilation failed"); + } + + // Open outputs if --open requested; collect server handles for live reload + let mut open_handles: Vec = Vec::new(); + if open { + for plugin in &ctx.plugins { + let out_dir = ctx.output_config.dir_for_plugin(plugin.name()); + match plugin.open(&out_dir, plugin.name()) { + Ok(handle) => open_handles.push(handle), + Err(e) => warn!(error = %e, plugin = plugin.name(), "failed to open"), + } + } + } + + let watch_project_cfg = ctx.project.clone(); + let build_dir_canonical = ctx + .output_config + .base + .canonicalize() + .unwrap_or_else(|_| ctx.output_config.base.clone()); + + watch_project(&watch_project_cfg, &build_dir_canonical, move |event| { + match event { + WatchEvent::FilesChanged => { + info!("files changed, recompiling"); + if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None).is_ok() + { + for handle in &open_handles { + if let OpenHandle::Server(server) = handle { + server.reload(); + } + } + } + } + WatchEvent::ConfigChanged => { + info!("config changed, reloading"); + match setup_compilation_context( + &path, + config_path.as_deref(), + build_dir.clone(), + enabled.clone(), + ) { + Ok(new_ctx) => { + ctx = new_ctx; + if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None) + .is_ok() + { + for handle in &open_handles { + if let OpenHandle::Server(server) = handle { + server.reload(); + } + } + } + } + Err(e) => warn!(error = %e, "failed to reload config"), + } + } + } + Ok(()) + }) +} + +fn run_compile(sub: &ArgMatches) -> Result<()> { + let path = PathBuf::from(sub.get_one::("path").unwrap()); + let config = sub.get_one::("config").map(PathBuf::from); + let build_dir = sub.get_one::("build-dir").map(PathBuf::from); + + let all = all_plugins(); + let enabled = enabled_formats_from_matches(sub, &all); + + let ctx = setup_compilation_context(&path, config.as_deref(), build_dir, enabled)?; + + perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins, None) +} + +fn run_clean(sub: &ArgMatches) -> Result<()> { + let path = PathBuf::from(sub.get_one::("path").unwrap()); + let config = sub.get_one::("config").map(PathBuf::from); + let build_dir = sub.get_one::("build-dir").map(PathBuf::from); + + info!(path = %path.display(), "loading project"); + let project = ProjectConfig::from_path(&path, config.as_deref())?; + let resolved_build_dir = resolve_build_dir(&project, build_dir)?; + let output_config = OutputConfig::new(&project.root, resolved_build_dir); + info!(project = %project.name, "cleaning build artifacts"); + output_config.clean()?; + info!(project = %project.name, "build artifacts removed"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_determine_formats_cli_flags_override_config() { + let all = all_plugins(); + let config_defaults = vec!["pdf".to_string()]; + let enabled = vec!["pdf".to_string()]; + + let formats = determine_formats(enabled, &config_defaults, &all); + assert_eq!(formats.len(), 1); + assert!(formats.contains(&"pdf".to_string())); + } + + #[test] + fn test_determine_formats_uses_config_defaults_when_no_flags() { + let all = all_plugins(); + let config_defaults = vec!["html".to_string()]; + let enabled: Vec = vec![]; + + let formats = determine_formats(enabled, &config_defaults, &all); + assert_eq!(formats.len(), 1); + assert!(formats.contains(&"html".to_string())); + } + + #[test] + fn test_determine_formats_falls_back_to_all_when_empty() { + let all = all_plugins(); + let config_defaults: Vec = vec![]; + let enabled: Vec = vec![]; + + let formats = determine_formats(enabled, &config_defaults, &all); + // Should contain all plugin names + assert_eq!(formats.len(), all_plugins().len()); + assert!(formats.contains(&"pdf".to_string())); + assert!(formats.contains(&"html".to_string())); + assert!(formats.contains(&"epub".to_string())); + } + + #[test] + fn test_determine_formats_multiple_cli_flags() { + let all = all_plugins(); + let config_defaults = vec!["epub".to_string()]; + let enabled = vec!["pdf".to_string(), "html".to_string()]; + + let formats = determine_formats(enabled, &config_defaults, &all); + assert_eq!(formats.len(), 2); + assert!(formats.contains(&"pdf".to_string())); + assert!(formats.contains(&"html".to_string())); + } + + #[test] + fn test_all_plugins_contains_three_formats() { + let plugins = all_plugins(); + let names: Vec<&str> = plugins.iter().map(|p| p.name()).collect(); + assert!(names.contains(&"html")); + assert!(names.contains(&"pdf")); + assert!(names.contains(&"epub")); + assert!( + names.len() >= 3, + "Expected at least 3 plugins, got {}", + names.len() + ); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..833d33b --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,5 @@ +use rheo_core::Result; + +fn main() -> Result<()> { + rheo_cli::run() +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..c5f245a --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "rheo-core" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# Typst dependencies +typst = { workspace = true } +typst-html = { workspace = true } +typst-pdf = { workspace = true } +typst-library = { workspace = true } +typst-kit = { workspace = true } +typst-syntax = { workspace = true } +comemo = { workspace = true } + +# Error handling and diagnostics +codespan-reporting = { workspace = true } +thiserror = { workspace = true } + +# Serialization +serde = { workspace = true } +toml = { workspace = true } +semver = { workspace = true } + +# File system and patterns +walkdir = { workspace = true } +globset = { workspace = true } +glob = { workspace = true } +pathdiff = { workspace = true } +opener = { workspace = true } + +# Date/time +chrono = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +atty = { workspace = true } + +# Concurrency and utilities +parking_lot = { workspace = true } +lazy_static = { workspace = true } +regex = { workspace = true } +notify = { workspace = true } +ecow = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/src/rs/compile.rs b/crates/core/src/compile.rs similarity index 55% rename from src/rs/compile.rs rename to crates/core/src/compile.rs index 11539d3..df8e4b5 100644 --- a/src/rs/compile.rs +++ b/crates/core/src/compile.rs @@ -4,88 +4,75 @@ use std::path::PathBuf; /// Common compilation options used across all output formats. /// /// This struct encapsulates the core parameters needed for any compilation: -/// - Input file (the .typ file to compile) +/// - Input file (the .typ file to compile, or `None` for merged/spine compilation) /// - Output file (where to write the result) /// - Root directory (for resolving imports) -/// - Optional RheoWorld (for incremental compilation) +/// - RheoWorld (`Some` in single-file mode, `None` in merged/spine mode) +/// +/// # Merged mode contract +/// For merged plugins (e.g. PDF spine, EPUB), `input` is `None` and `world` is +/// `None`. Use `ctx.spine` to locate the files to compile; the plugin creates +/// its own worlds per spine file. pub struct RheoCompileOptions<'a> { - /// The input .typ file to compile - pub input: PathBuf, + /// The input .typ file to compile, or `None` in merged/spine mode. + pub input: Option, /// The output file path pub output: PathBuf, /// Root directory for resolving imports pub root: PathBuf, - /// Optional existing RheoWorld for incremental compilation + /// RheoWorld for compilation. `Some` in single-file mode; `None` in merged/spine mode. pub world: Option<&'a mut RheoWorld>, } impl<'a> RheoCompileOptions<'a> { - /// Create compilation options for a fresh (non-incremental) compilation. + /// Create compilation options. /// /// # Arguments - /// * `input` - The input .typ file to compile + /// * `input` - The input .typ file, or `None` for merged/spine compilation /// * `output` - The output file path /// * `root` - Root directory for resolving imports + /// * `world` - `Some` with the RheoWorld in single-file mode, `None` in merged/spine mode pub fn new( - input: impl Into, + input: Option>, output: impl Into, root: impl Into, + world: Option<&'a mut RheoWorld>, ) -> Self { Self { - input: input.into(), + input: input.map(Into::into), output: output.into(), root: root.into(), - world: None, - } - } - - /// Create compilation options for incremental compilation. - /// - /// Reuses an existing RheoWorld for faster recompilation. - /// - /// # Arguments - /// * `input` - The input .typ file to compile - /// * `output` - The output file path - /// * `root` - Root directory for resolving imports - /// * `world` - Mutable reference to existing RheoWorld - pub fn incremental( - input: impl Into, - output: impl Into, - root: impl Into, - world: &'a mut RheoWorld, - ) -> Self { - Self { - input: input.into(), - output: output.into(), - root: root.into(), - world: Some(world), + world, } } } #[cfg(test)] mod tests { - use crate::formats::pdf; + use crate::pdf_utils; #[test] fn test_filename_to_title() { assert_eq!( - pdf::DocumentTitle::to_readable_name("severance-ep-1"), + pdf_utils::DocumentTitle::to_readable_name("severance-ep-1"), "Severance Ep 1" ); assert_eq!( - pdf::DocumentTitle::to_readable_name("my_document"), + pdf_utils::DocumentTitle::to_readable_name("my_document"), "My Document" ); assert_eq!( - pdf::DocumentTitle::to_readable_name("chapter-01"), + pdf_utils::DocumentTitle::to_readable_name("chapter-01"), "Chapter 01" ); assert_eq!( - pdf::DocumentTitle::to_readable_name("hello_world"), + pdf_utils::DocumentTitle::to_readable_name("hello_world"), "Hello World" ); - assert_eq!(pdf::DocumentTitle::to_readable_name("single"), "Single"); + assert_eq!( + pdf_utils::DocumentTitle::to_readable_name("single"), + "Single" + ); } #[test] @@ -95,7 +82,7 @@ mod tests { = Chapter 1 Content here."#; - let title = pdf::DocumentTitle::from_source(source, "fallback").extract(); + let title = pdf_utils::DocumentTitle::from_source(source, "fallback").extract(); assert_eq!(title, "My Great Title"); } @@ -104,7 +91,7 @@ Content here."#; let source = r#"= Chapter 1 Content here."#; - let title = pdf::DocumentTitle::from_source(source, "my-chapter").extract(); + let title = pdf_utils::DocumentTitle::from_source(source, "my-chapter").extract(); assert_eq!(title, "My Chapter"); } @@ -112,7 +99,7 @@ Content here."#; fn test_extract_document_title_with_markup() { let source = r#"#set document(title: [Good news about hell - #emph[Severance]])"#; - let title = pdf::DocumentTitle::from_source(source, "fallback").extract(); + let title = pdf_utils::DocumentTitle::from_source(source, "fallback").extract(); // Should strip #emph and underscores // Note: complex nested bracket handling is limited by regex assert!(title.contains("Good news")); @@ -125,7 +112,7 @@ Content here."#; Content"#; - let title = pdf::DocumentTitle::from_source(source, "default-name").extract(); + let title = pdf_utils::DocumentTitle::from_source(source, "default-name").extract(); // Empty title should fall back to filename assert_eq!(title, "Default Name"); } @@ -134,7 +121,7 @@ Content"#; fn test_extract_document_title_complex() { let source = r#"#set document(title: [Half Loop - _Severance_ [s1/e2]], author: [Test])"#; - let title = pdf::DocumentTitle::from_source(source, "fallback").extract(); + let title = pdf_utils::DocumentTitle::from_source(source, "fallback").extract(); // Should extract title and strip markup assert!(title.contains("Half Loop")); assert!(title.contains("Severance")); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..da4950f --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,497 @@ +use crate::Result; +use crate::manifest_version::ManifestVersion; +use crate::validation::ValidateConfig; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; +use tracing::debug; + +/// Spine configuration from `rheo.toml`: glob patterns, title, and optional merge flag. +/// +/// All format plugins share this single config type. Each plugin interprets the +/// `merge` field according to its own defaults (e.g. EPUB defaults to merge=true). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Spine { + /// Title for the merged output document (required when merge=true). + pub title: Option, + + /// Glob patterns for files to include, evaluated relative to content_dir. + /// Results are sorted lexicographically within each pattern. + /// Empty = auto-discover all .typ files. + #[serde(default)] + pub vertebrae: Vec, + + /// Whether to merge vertebrae into a single output file. + /// `None` means "use the plugin's default" (false for PDF/HTML, true for EPUB). + pub merge: Option, +} + +/// Plugin section for `[plugin_name]` in rheo.toml. +/// +/// Contains the universal `spine` field plus format-specific extra fields in +/// `extra`. Each plugin reads only the keys it knows about from `extra`; unknown +/// keys are silently ignored. Adding a new plugin requires no changes to this +/// struct. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PluginSection { + /// Spine configuration (shared by all plugins). + pub spine: Option, + + /// Per-plugin glob patterns for files to copy into this plugin's output directory. + /// Paths are relative to the project root; directory structure is preserved. + #[serde(default)] + pub copy: Vec, + + /// Plugin-specific extra fields from the TOML section (e.g. `stylesheets`, + /// `fonts` for HTML; `identifier`, `date` for EPUB). + #[serde(flatten, default)] + pub extra: toml::Table, +} + +/// Configuration for rheo compilation. +/// +/// Loaded from `rheo.toml`. Unknown top-level table sections are parsed as +/// `PluginSection` entries (keyed by section name), so adding a new format +/// plugin requires no changes to this struct. +#[derive(Debug, Clone)] +pub struct RheoConfig { + /// Manifest version for API compatibility (required). + pub version: ManifestVersion, + + /// Directory containing .typ content files (relative to project root). + pub content_dir: Option, + + /// Build output directory (relative to project root unless absolute). + pub build_dir: Option, + + /// Default formats to compile when no CLI flags are specified. + /// Empty = fall back to all registered plugins. + pub formats: Vec, + + /// Global glob patterns for files to copy into every plugin's output directory. + /// Paths are relative to the project root; directory structure is preserved. + pub copy: Vec, + + /// Per-plugin configuration sections, keyed by plugin name. + /// Built from `[html]`, `[pdf]`, `[epub]` (and any other) table sections. + pub plugin_sections: HashMap, +} + +impl Default for RheoConfig { + fn default() -> Self { + Self { + version: ManifestVersion::current(), + content_dir: Some("./".to_string()), + build_dir: Some("./build".to_string()), + formats: vec![], + copy: vec![], + plugin_sections: HashMap::new(), + } + } +} + +/// Raw intermediate for custom deserialization of `RheoConfig`. +#[derive(Debug, Deserialize)] +pub struct RheoConfigRaw { + version: ManifestVersion, + content_dir: Option, + build_dir: Option, + #[serde(default)] + formats: Vec, + #[serde(default)] + copy: Vec, + #[serde(flatten)] + extra: HashMap, +} + +impl TryFrom for RheoConfig { + type Error = toml::de::Error; + + fn try_from(raw: RheoConfigRaw) -> std::result::Result { + let mut plugin_sections = HashMap::new(); + for (key, value) in raw.extra { + if let toml::Value::Table(_) = &value { + let section: PluginSection = value.try_into()?; + plugin_sections.insert(key, section); + } + // Non-table entries (unknown scalar fields) are silently ignored. + } + Ok(RheoConfig { + version: raw.version, + content_dir: raw.content_dir, + build_dir: raw.build_dir, + formats: raw.formats, + copy: raw.copy, + plugin_sections, + }) + } +} + +impl RheoConfig { + /// Load configuration from rheo.toml in the given directory. + /// If the file doesn't exist, returns default configuration. + pub fn load(project_root: &Path) -> Result { + let config_path = project_root.join("rheo.toml"); + + if !config_path.exists() { + debug!(path = %config_path.display(), "no rheo.toml found, using defaults"); + return Ok(Self::default()); + } + + debug!(path = %config_path.display(), "loading configuration"); + let contents = std::fs::read_to_string(&config_path) + .map_err(|e| crate::RheoError::io(e, format!("reading {}", config_path.display())))?; + + let raw: RheoConfigRaw = toml::from_str(&contents) + .map_err(|e| crate::RheoError::project_config(format!("invalid rheo.toml: {}", e)))?; + let config = RheoConfig::try_from(raw) + .map_err(|e| crate::RheoError::project_config(format!("invalid rheo.toml: {}", e)))?; + + config.validate()?; + Ok(config) + } + + /// Load configuration from a specific path with validation. + pub fn load_from_path(config_path: &Path) -> Result { + if !config_path.exists() { + return Err(crate::RheoError::path( + config_path, + "config file does not exist", + )); + } + if !config_path.is_file() { + return Err(crate::RheoError::path( + config_path, + "config path must be a file, not a directory", + )); + } + + let contents = std::fs::read_to_string(config_path).map_err(|e| { + crate::RheoError::io(e, format!("reading config file {}", config_path.display())) + })?; + + let raw: RheoConfigRaw = toml::from_str(&contents).map_err(|e| { + crate::RheoError::project_config(format!( + "invalid config file {}: {}", + config_path.display(), + e + )) + })?; + let config = RheoConfig::try_from(raw).map_err(|e| { + crate::RheoError::project_config(format!( + "invalid config file {}: {}", + config_path.display(), + e + )) + })?; + + config.validate()?; + + debug!(path = %config_path.display(), "loaded custom configuration"); + Ok(config) + } + + /// Resolve content_dir to an absolute path if configured. + pub fn resolve_content_dir(&self, base_dir: &Path) -> Option { + self.content_dir.as_ref().map(|dir| { + let path = base_dir.join(dir); + debug!(content_dir = %path.display(), "resolved content directory"); + path + }) + } + + /// Returns true if `name` appears in the configured formats list. + pub fn has_format(&self, name: &str) -> bool { + self.formats.iter().any(|f| f == name) + } + + /// Return the spine config for the named plugin, if any. + pub fn spine_for_plugin(&self, name: &str) -> Option<&Spine> { + self.plugin_sections + .get(name) + .and_then(|s| s.spine.as_ref()) + } + + /// Return the full plugin section for the named plugin. + /// Returns `PluginSection::default()` if no section is configured. + pub fn plugin_section(&self, name: &str) -> PluginSection { + self.plugin_sections.get(name).cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a test TOML string with the current crate version prepended. + fn versioned_toml(rest: &str) -> String { + format!("version = \"{}\"\n{}", env!("CARGO_PKG_VERSION"), rest) + } + + fn parse(toml: &str) -> RheoConfig { + let raw: RheoConfigRaw = toml::from_str(toml).expect("parse failed"); + RheoConfig::try_from(raw).expect("convert failed") + } + + #[test] + fn test_default_config() { + let config = RheoConfig::default(); + // formats is empty by default — CLI falls back to all_plugins() + assert!(config.formats.is_empty()); + assert_eq!(config.version, ManifestVersion::current()); + } + + #[test] + fn test_config_missing_version_field() { + let toml = r#" + content_dir = "content" + formats = ["pdf"] + "#; + + let result = toml::from_str::(toml); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("missing field") || err_msg.contains("version")); + } + + #[test] + fn test_formats_from_config() { + let toml = versioned_toml(r#"formats = ["pdf"]"#); + let config = parse(&toml); + assert_eq!(config.formats, vec!["pdf"]); + } + + #[test] + fn test_formats_defaults_when_not_specified() { + let toml = versioned_toml(""); + let config = parse(&toml); + // When not specified, formats is empty (CLI falls back to all_plugins()) + assert!(config.formats.is_empty()); + } + + #[test] + fn test_formats_multiple_values() { + let toml = versioned_toml(r#"formats = ["html", "epub"]"#); + let config = parse(&toml); + assert_eq!(config.formats, vec!["html", "epub"]); + } + + #[test] + fn test_formats_stored_as_given() { + let toml = versioned_toml(r#"formats = ["pdf", "html", "epub"]"#); + let config = parse(&toml); + assert_eq!(config.formats, vec!["pdf", "html", "epub"]); + } + + #[test] + fn test_load_from_path_not_found() { + use std::path::PathBuf; + + let path = PathBuf::from("/tmp/nonexistent_config_12345_rheo_test.toml"); + let result = RheoConfig::load_from_path(&path); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("config file does not exist")); + } + + #[test] + fn test_load_from_path_is_directory() { + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let result = RheoConfig::load_from_path(temp.path()); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("must be a file, not a directory")); + } + + #[test] + fn test_load_from_path_invalid_toml() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("invalid.toml"); + fs::write(&config_path, "[this is not valid toml").unwrap(); + + let result = RheoConfig::load_from_path(&config_path); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("invalid config file")); + } + + #[test] + fn test_html_section_defaults() { + // When no [html] section, plugin_section("html") returns default (empty extra) + let config = RheoConfig::default(); + let section = config.plugin_section("html"); + assert!(section.spine.is_none()); + assert!(section.extra.is_empty()); + } + + #[test] + fn test_html_section_custom_stylesheets() { + let toml = versioned_toml("[html]\nstylesheets = [\"custom.css\", \"theme.css\"]"); + let config = parse(&toml); + let section = config.plugin_section("html"); + let sheets = section + .extra + .get("stylesheets") + .and_then(|v| v.as_array()) + .unwrap(); + assert_eq!(sheets.len(), 2); + assert_eq!(sheets[0].as_str().unwrap(), "custom.css"); + assert_eq!(sheets[1].as_str().unwrap(), "theme.css"); + } + + #[test] + fn test_html_section_custom_fonts() { + let toml = versioned_toml("[html]\nfonts = [\"https://example.com/font.css\"]"); + let config = parse(&toml); + let section = config.plugin_section("html"); + let fonts = section + .extra + .get("fonts") + .and_then(|v| v.as_array()) + .unwrap(); + assert_eq!(fonts[0].as_str().unwrap(), "https://example.com/font.css"); + } + + #[test] + fn test_pdf_spine_with_merge_true() { + let toml = versioned_toml( + "[pdf.spine]\ntitle = \"My Book\"\nvertebrae = [\"cover.typ\", \"chapters/*.typ\"]\nmerge = true", + ); + let config = parse(&toml); + let spine = config.spine_for_plugin("pdf").unwrap(); + assert_eq!(spine.title.as_ref().unwrap(), "My Book"); + assert_eq!(spine.vertebrae, vec!["cover.typ", "chapters/*.typ"]); + assert_eq!(spine.merge, Some(true)); + } + + #[test] + fn test_pdf_spine_with_merge_false() { + let toml = versioned_toml( + "[pdf.spine]\ntitle = \"My Book\"\nvertebrae = [\"cover.typ\", \"chapters/*.typ\"]\nmerge = false", + ); + let config = parse(&toml); + let spine = config.spine_for_plugin("pdf").unwrap(); + assert_eq!(spine.merge, Some(false)); + } + + #[test] + fn test_pdf_spine_merge_omitted() { + let toml = versioned_toml("[pdf.spine]\ntitle = \"My Book\"\nvertebrae = [\"cover.typ\"]"); + let config = parse(&toml); + let spine = config.spine_for_plugin("pdf").unwrap(); + assert_eq!(spine.merge, None); + } + + #[test] + fn test_epub_spine() { + let toml = versioned_toml( + "[epub.spine]\ntitle = \"My EPUB\"\nvertebrae = [\"intro.typ\", \"chapter*.typ\", \"outro.typ\"]", + ); + let config = parse(&toml); + let spine = config.spine_for_plugin("epub").unwrap(); + assert_eq!(spine.title.as_deref().unwrap(), "My EPUB"); + assert_eq!( + spine.vertebrae, + vec!["intro.typ", "chapter*.typ", "outro.typ"] + ); + } + + #[test] + fn test_html_spine() { + let toml = versioned_toml( + "[html.spine]\ntitle = \"My Website\"\nvertebrae = [\"index.typ\", \"about.typ\"]", + ); + let config = parse(&toml); + let spine = config.spine_for_plugin("html").unwrap(); + assert_eq!(spine.title.as_ref().unwrap(), "My Website"); + assert_eq!(spine.vertebrae, vec!["index.typ", "about.typ"]); + } + + #[test] + fn test_spine_empty_vertebrae() { + let toml = versioned_toml("[epub.spine]\ntitle = \"Single File Book\"\nvertebrae = []"); + let config = parse(&toml); + let spine = config.spine_for_plugin("epub").unwrap(); + assert_eq!(spine.title.as_deref().unwrap(), "Single File Book"); + assert!(spine.vertebrae.is_empty()); + } + + #[test] + fn test_spine_complex_glob_patterns() { + let toml = versioned_toml( + "[pdf.spine]\ntitle = \"Complex Book\"\nvertebrae = [\"frontmatter/**/*.typ\", \"chapters/**/ch*.typ\", \"appendix.typ\"]\nmerge = true", + ); + let config = parse(&toml); + let spine = config.spine_for_plugin("pdf").unwrap(); + assert_eq!(spine.vertebrae.len(), 3); + assert_eq!(spine.vertebrae[0], "frontmatter/**/*.typ"); + assert_eq!(spine.vertebrae[1], "chapters/**/ch*.typ"); + assert_eq!(spine.vertebrae[2], "appendix.typ"); + } + + #[test] + fn test_has_format() { + let toml = versioned_toml(r#"formats = ["html", "pdf"]"#); + let config = parse(&toml); + assert!(config.has_format("html")); + assert!(config.has_format("pdf")); + assert!(!config.has_format("epub")); + } + + #[test] + fn test_epub_identifier_and_date() { + let toml = + versioned_toml("[epub]\nidentifier = \"urn:uuid:12345\"\ndate = 2025-01-15T00:00:00Z"); + let config = parse(&toml); + let section = config.plugin_section("epub"); + assert_eq!( + section.extra.get("identifier").and_then(|v| v.as_str()), + Some("urn:uuid:12345") + ); + assert!(section.extra.get("date").is_some()); + } + + #[test] + fn test_global_copy_parses() { + let toml = versioned_toml(r#"copy = ["*.txt", "assets/**/*.png"]"#); + let config = parse(&toml); + assert_eq!(config.copy, vec!["*.txt", "assets/**/*.png"]); + } + + #[test] + fn test_global_copy_defaults_empty() { + let toml = versioned_toml(""); + let config = parse(&toml); + assert!(config.copy.is_empty()); + } + + #[test] + fn test_plugin_copy_parses() { + let toml = versioned_toml("[html]\ncopy = [\"assets/logo.png\", \"fonts/**\"]"); + let config = parse(&toml); + let section = config.plugin_section("html"); + assert_eq!(section.copy, vec!["assets/logo.png", "fonts/**"]); + } + + #[test] + fn test_plugin_copy_not_in_extra() { + let toml = versioned_toml("[html]\ncopy = [\"assets/logo.png\"]"); + let config = parse(&toml); + let section = config.plugin_section("html"); + // `copy` must be in the dedicated field, not leaked into `extra` + assert!(section.extra.get("copy").is_none()); + } + + #[test] + fn test_plugin_copy_defaults_empty() { + let toml = versioned_toml("[html]\nstylesheets = [\"style.css\"]"); + let config = parse(&toml); + let section = config.plugin_section("html"); + assert!(section.copy.is_empty()); + } +} diff --git a/src/rs/constants.rs b/crates/core/src/constants.rs similarity index 100% rename from src/rs/constants.rs rename to crates/core/src/constants.rs diff --git a/src/rs/formats/common.rs b/crates/core/src/diagnostics.rs similarity index 100% rename from src/rs/formats/common.rs rename to crates/core/src/diagnostics.rs diff --git a/src/rs/error.rs b/crates/core/src/error.rs similarity index 100% rename from src/rs/error.rs rename to crates/core/src/error.rs diff --git a/crates/core/src/html_compile.rs b/crates/core/src/html_compile.rs new file mode 100644 index 0000000..39cfd10 --- /dev/null +++ b/crates/core/src/html_compile.rs @@ -0,0 +1,46 @@ +use crate::Result; +use crate::diagnostics::{ExportErrorType, handle_export_errors, unwrap_compilation_result}; +use crate::world::RheoWorld; +use std::path::Path; +use tracing::info; +use typst::diag::SourceDiagnostic; +use typst_html::HtmlDocument; + +pub fn compile_html_to_document( + input: &Path, + root: &Path, + format_name: &str, + plugin_library: Option, +) -> Result { + let world = RheoWorld::new(root, input, Some(format_name), plugin_library)?; + info!(input = %input.display(), "compiling to HTML"); + let result = typst::compile::(&world); + + let html_filter = |w: &SourceDiagnostic| { + !w.message + .contains("html export is under active development and incomplete") + }; + + unwrap_compilation_result(Some(&world), result, Some(html_filter)) +} + +/// Compile using an existing RheoWorld to an HTML document. +/// +/// This function uses a pre-configured RheoWorld (with main file already set) +/// and compiles it to an HtmlDocument. Useful for per-file compilation where +/// the world is shared across multiple files. +pub fn compile_html_with_world(world: &RheoWorld) -> Result { + info!("compiling to HTML"); + let result = typst::compile::(world); + + let html_filter = |w: &SourceDiagnostic| { + !w.message + .contains("html export is under active development and incomplete") + }; + + unwrap_compilation_result(Some(world), result, Some(html_filter)) +} + +pub fn compile_document_to_string(document: &HtmlDocument) -> Result { + typst_html::html(document).map_err(|e| handle_export_errors(e, ExportErrorType::Html)) +} diff --git a/crates/core/src/init_templates.rs b/crates/core/src/init_templates.rs new file mode 100644 index 0000000..f43f9df --- /dev/null +++ b/crates/core/src/init_templates.rs @@ -0,0 +1,5 @@ +pub const RHEO_TOML: &str = include_str!("templates/init/rheo.toml"); +pub const CONTENT_INDEX_TYP: &str = include_str!("templates/init/content/index.typ"); +pub const CONTENT_ABOUT_TYP: &str = include_str!("templates/init/content/about.typ"); +pub const CONTENT_REFERENCES_BIB: &str = include_str!("templates/init/content/references.bib"); +pub const CONTENT_IMG_HEADER_SVG: &str = include_str!("templates/init/content/img/header.svg"); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..ac3bc3d --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,101 @@ +pub mod compile; +pub mod config; +pub mod constants; +pub mod diagnostics; +pub mod error; +pub mod html_compile; +pub mod init_templates; +pub mod logging; +pub mod manifest_version; +pub mod output; +pub mod path_utils; +pub mod pdf_compile; +pub mod pdf_utils; +pub mod plugins; +pub mod project; +pub mod results; +pub mod reticulate; +pub mod typst_types; +pub mod unified_compile; +pub mod validation; +pub mod watch; +pub mod world; + +// Note: Cli is now in rheo-cli crate, not exported here + +// === Core types (already exported) === +pub use config::RheoConfig; +pub use constants::*; +pub use error::RheoError; +pub use globset::{Glob, GlobSet, GlobSetBuilder}; +pub use manifest_version::ManifestVersion; +pub use path_utils::PathExt; +pub use results::{CompilationResults, FormatResult}; + +// === Plugin API re-exports === + +// Compile options and context +pub use compile::RheoCompileOptions; + +// Configuration types +pub use config::{PluginSection, Spine}; + +// Plugin trait and context +pub use plugins::{ + FormatPlugin, OpenHandle, PluginContext, PluginInput, ServerHandle, SpineOptions, +}; + +// HTML compilation functions +pub use html_compile::{ + compile_document_to_string, compile_html_to_document, compile_html_with_world, +}; + +// PDF compilation functions +pub use pdf_compile::{compile_pdf_to_document, compile_pdf_with_world, document_to_pdf_bytes}; + +// Unified compilation API (consistent naming pattern) +pub use unified_compile::{ + HtmlDocument as HtmlDoc, HtmlString, PagedDocument as PdfDoc, PdfBytes, + compile_to_html_document, compile_to_html_document_with_world, compile_to_html_string, + compile_to_pdf_bytes, compile_to_pdf_document, compile_to_pdf_document_with_world, +}; + +// World (Typst compilation context) +pub use world::RheoWorld; + +// Re-export reticulate module for spine building +pub use reticulate::spine::BuiltSpine; + +// PDF utilities +pub use pdf_utils::DocumentTitle; + +// Typst types (commonly used by plugins) +pub use typst_types::{ + EcoString, HeadingElem, HtmlDocument, NativeElement, OutlineNode, StyleChain, eco_format, + eco_vec, +}; + +use std::path::PathBuf; +use tracing::{info, warn}; +use walkdir::WalkDir; + +/// Result type alias using RheoError +pub type Result = std::result::Result; + +pub fn open_all_files_in_folder(folder: PathBuf, ext: &str) -> Result<()> { + for entry in WalkDir::new(&folder) + .max_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some(ext)) + { + let path = entry.path(); + info!("Opening: {}", path.display()); + + if let Err(e) = opener::open(path) { + warn!("Failed to open {}: {}", path.display(), e); + } + } + + Ok(()) +} diff --git a/src/rs/logging.rs b/crates/core/src/logging.rs similarity index 100% rename from src/rs/logging.rs rename to crates/core/src/logging.rs diff --git a/src/rs/manifest_version.rs b/crates/core/src/manifest_version.rs similarity index 100% rename from src/rs/manifest_version.rs rename to crates/core/src/manifest_version.rs diff --git a/crates/core/src/output.rs b/crates/core/src/output.rs new file mode 100644 index 0000000..fc75f35 --- /dev/null +++ b/crates/core/src/output.rs @@ -0,0 +1,130 @@ +use crate::{Result, RheoError}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Output directory configuration for a project +#[derive(Debug)] +pub struct OutputConfig { + /// Base build directory (e.g. project_root/build) + pub base: PathBuf, +} + +impl OutputConfig { + /// Create output configuration for a project + /// + /// Outputs to {build_dir}/{plugin_name}/ where build_dir defaults to {project_root}/build + pub fn new(project_root: &Path, build_dir: Option) -> Self { + let base = match build_dir { + Some(custom) => custom, + None => project_root.join("build"), + }; + OutputConfig { base } + } + + /// Get the output directory for a given plugin name + pub fn dir_for_plugin(&self, name: &str) -> PathBuf { + self.base.join(name) + } + + /// Clean this project's build artifacts + pub fn clean(&self) -> Result<()> { + if self.base.exists() { + fs::remove_dir_all(&self.base) + .map_err(|e| RheoError::io(e, format!("removing directory {:?}", self.base)))?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_output_config_new() { + let project_root = PathBuf::from("/home/user/my-book"); + let config = OutputConfig::new(&project_root, None); + + assert_eq!(config.base, PathBuf::from("/home/user/my-book/build")); + assert_eq!( + config.dir_for_plugin("pdf"), + PathBuf::from("/home/user/my-book/build/pdf") + ); + assert_eq!( + config.dir_for_plugin("html"), + PathBuf::from("/home/user/my-book/build/html") + ); + assert_eq!( + config.dir_for_plugin("epub"), + PathBuf::from("/home/user/my-book/build/epub") + ); + } + + #[test] + fn test_output_config_custom_build_dir() { + let project_root = PathBuf::from("/home/user/my-book"); + let custom_build = PathBuf::from("/tmp/rheo-output"); + let config = OutputConfig::new(&project_root, Some(custom_build)); + + assert_eq!(config.base, PathBuf::from("/tmp/rheo-output")); + assert_eq!( + config.dir_for_plugin("pdf"), + PathBuf::from("/tmp/rheo-output/pdf") + ); + assert_eq!( + config.dir_for_plugin("html"), + PathBuf::from("/tmp/rheo-output/html") + ); + assert_eq!( + config.dir_for_plugin("epub"), + PathBuf::from("/tmp/rheo-output/epub") + ); + } + + #[test] + fn test_clean() { + let temp_dir = std::env::temp_dir().join("rheo_test_clean"); + + // Clean up any previous test runs + let _ = fs::remove_dir_all(&temp_dir); + + let config = OutputConfig::new(&temp_dir, None); + + // Create directories and some dummy files + fs::create_dir_all(config.dir_for_plugin("pdf")).expect("Failed to create pdf dir"); + fs::create_dir_all(config.dir_for_plugin("html")).expect("Failed to create html dir"); + fs::write(config.dir_for_plugin("pdf").join("test.pdf"), b"dummy pdf") + .expect("Failed to write test file"); + fs::write( + config.dir_for_plugin("html").join("test.html"), + b"dummy html", + ) + .expect("Failed to write test file"); + + // Verify directories exist + assert!(config.dir_for_plugin("pdf").exists()); + assert!(config.dir_for_plugin("html").exists()); + + // Clean project + config.clean().expect("Failed to clean project"); + + // Verify build directory is gone + assert!( + !temp_dir.join("build").exists(), + "Build directory should be removed" + ); + + // Clean up + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_clean_nonexistent_directory() { + let nonexistent = PathBuf::from("/tmp/rheo_nonexistent_test_xyz"); + let config = OutputConfig::new(&nonexistent, None); + + // Should not error when cleaning non-existent directory + assert!(config.clean().is_ok()); + } +} diff --git a/src/rs/path_utils.rs b/crates/core/src/path_utils.rs similarity index 100% rename from src/rs/path_utils.rs rename to crates/core/src/path_utils.rs diff --git a/crates/core/src/pdf_compile.rs b/crates/core/src/pdf_compile.rs new file mode 100644 index 0000000..b133520 --- /dev/null +++ b/crates/core/src/pdf_compile.rs @@ -0,0 +1,90 @@ +use crate::Result; +/// PDF compilation wrappers for rheo plugins. +/// +/// These functions encapsulate Typst PDF compilation, allowing plugin crates +/// to compile PDFs without directly importing typst crates. +use crate::diagnostics::{ExportErrorType, handle_export_errors, unwrap_compilation_result}; +use crate::world::RheoWorld; +use std::path::Path; +use tracing::info; +use typst::layout::PagedDocument; + +/// Compile a Typst source file to a PDF document. +/// +/// This function creates a new RheoWorld for the given input and compiles +/// it to a PagedDocument. The result can be exported to PDF bytes using +/// `document_to_pdf_bytes`. +/// +/// # Arguments +/// * `input` - Path to the .typ file to compile +/// * `root` - Project root directory for resolving imports +/// * `format_name` - Output format name for link transformations (e.g., "pdf", None) +/// * `plugin_library` - Optional plugin-contributed Typst library code to inject +/// +/// # Returns +/// A PagedDocument ready for PDF export +/// +/// # Example +/// ```ignore +/// let document = compile_pdf_to_document(&input_path, &project_root, Some("pdf"), None)?; +/// let pdf_bytes = document_to_pdf_bytes(&document)?; +/// std::fs::write("output.pdf", &pdf_bytes)?; +/// ``` +pub fn compile_pdf_to_document( + input: &Path, + root: &Path, + format_name: Option<&str>, + plugin_library: Option, +) -> Result { + let world = RheoWorld::new(root, input, format_name, plugin_library)?; + info!(input = %input.display(), "compiling to PDF"); + let result = typst::compile::(&world); + unwrap_compilation_result(Some(&world), result, None:: bool>) +} + +/// Compile using an existing RheoWorld to a PDF document. +/// +/// This function uses a pre-configured RheoWorld (with main file already set) +/// and compiles it to a PagedDocument. Useful for per-file compilation where +/// the world is shared across multiple files. +/// +/// # Arguments +/// * `world` - A configured RheoWorld with the main file set +/// +/// # Returns +/// A PagedDocument ready for PDF export +/// +/// # Example +/// ```ignore +/// let document = compile_pdf_with_world(&world)?; +/// let pdf_bytes = document_to_pdf_bytes(&document)?; +/// std::fs::write("output.pdf", &pdf_bytes)?; +/// ``` +pub fn compile_pdf_with_world(world: &RheoWorld) -> Result { + info!("compiling to PDF"); + let result = typst::compile::(world); + unwrap_compilation_result(Some(world), result, None:: bool>) +} + +/// Export a PagedDocument to PDF bytes. +/// +/// Converts a compiled PagedDocument into its PDF representation as bytes. +/// The resulting bytes can be written directly to a file. +/// +/// # Arguments +/// * `document` - The compiled PagedDocument to export +/// +/// # Returns +/// PDF file content as a byte vector +/// +/// # Example +/// ```ignore +/// let document = compile_pdf_to_document(&input_path, &root, Some("pdf"))?; +/// let pdf_bytes = document_to_pdf_bytes(&document)?; +/// std::fs::write("output.pdf", &pdf_bytes)?; +/// ``` +pub fn document_to_pdf_bytes(document: &PagedDocument) -> Result> { + use typst_pdf::PdfOptions; + typst_pdf::pdf(document, &PdfOptions::default()) + .map_err(|e| handle_export_errors(e, ExportErrorType::Pdf)) +} diff --git a/crates/core/src/pdf_utils.rs b/crates/core/src/pdf_utils.rs new file mode 100644 index 0000000..9b8f524 --- /dev/null +++ b/crates/core/src/pdf_utils.rs @@ -0,0 +1,216 @@ +/// PDF utility functions shared across rheo core and plugins +use crate::constants::TYPST_LABEL_PATTERN; + +/// Sanitize a filename to create a valid Typst label name. +/// +/// Replaces non-alphanumeric characters (except hyphens and underscores) with underscores. +pub fn sanitize_label_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +/// Document title extractor that parses Typst source for title metadata. +/// +/// Provides methods for extracting document titles from Typst source code or +/// generating readable titles from filenames. +pub struct DocumentTitle { + source: String, + fallback_filename: String, +} + +impl DocumentTitle { + /// Create a DocumentTitle from source code and a fallback filename. + /// + /// # Arguments + /// * `source` - Typst source code to extract title from + /// * `fallback` - Filename to use if no title is found in source + pub fn from_source(source: impl Into, fallback: impl Into) -> Self { + Self { + source: source.into(), + fallback_filename: fallback.into(), + } + } + + /// Extract the document title. + /// + /// Searches for `#set document(title: [...])` in the source and extracts the content. + /// Falls back to the filename converted to title case if no title is found. + pub fn extract(&self) -> String { + // Find the start of the title parameter + if let Some(title_start) = self.source.find("#set document(") { + let after_doc = &self.source[title_start..]; + if let Some(title_pos) = after_doc.find("title:") { + let after_title = &after_doc[title_pos + 6..]; // Skip "title:" + + // Find the opening bracket for the title + // PDF metadata uses bracket-delimited format: /Title [(title text)] + if let Some(bracket_start) = after_title.find('[') { + let title_content = &after_title[bracket_start + 1..]; + + // Count brackets to find the matching closing bracket + // Handles nested brackets like: [(Chapter [1])] + // Algorithm: + // 1. Start with depth=1 (for the opening bracket we just found) + // 2. Scan forward, incrementing depth for '[', decrementing for ']' + // 3. When depth reaches 0, we've found the matching closing bracket + let mut depth = 1; + let mut end_pos = 0; + + for (i, ch) in title_content.chars().enumerate() { + if ch == '[' { + depth += 1; // Found nested opening bracket + } else if ch == ']' { + depth -= 1; // Found closing bracket + if depth == 0 { + // This is the matching closing bracket + end_pos = i; + break; + } + } + } + + if end_pos > 0 { + let title = &title_content[..end_pos]; + // Strip Typst markup for plain text + let cleaned = strip_typst_markup(title); + if !cleaned.trim().is_empty() { + return cleaned; + } + } + } + } + } + + // Fallback: use filename, convert to title case + Self::to_readable_name(&self.fallback_filename) + } + + /// Convert a filename to a readable title. + /// + /// Transforms a filename stem into a human-readable title by replacing + /// separators with spaces and capitalizing words. + /// + /// # Arguments + /// * `filename` - The filename to convert + /// + /// # Returns + /// A title-cased version of the filename + pub fn to_readable_name(filename: &str) -> String { + filename + .replace(['-', '_'], " ") + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect::>() + .join(" ") + } +} + +/// Strip basic Typst markup to get plain text. +/// +/// Removes common Typst markup patterns like #emph[...], #strong[...], +/// and italic markers (_) to extract plain text from formatted content. +fn strip_typst_markup(text: &str) -> String { + // Remove #emph[...], #strong[...], etc. + let result = TYPST_LABEL_PATTERN.replace_all(text, "$1"); + + // Remove underscores (italic markers) + let result = result.replace('_', ""); + + result.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_label_name() { + assert_eq!(sanitize_label_name("chapter 01"), "chapter_01"); + assert_eq!(sanitize_label_name("severance-01"), "severance-01"); + assert_eq!(sanitize_label_name("my_file!@#"), "my_file___"); + assert_eq!(sanitize_label_name("test.typ"), "test_typ"); + } + + #[test] + fn test_filename_to_title() { + assert_eq!( + DocumentTitle::to_readable_name("severance-ep-1"), + "Severance Ep 1" + ); + assert_eq!( + DocumentTitle::to_readable_name("my_document"), + "My Document" + ); + assert_eq!(DocumentTitle::to_readable_name("chapter-01"), "Chapter 01"); + assert_eq!( + DocumentTitle::to_readable_name("hello_world"), + "Hello World" + ); + assert_eq!(DocumentTitle::to_readable_name("single"), "Single"); + } + + #[test] + fn test_extract_document_title_from_metadata() { + let source = r#"#set document(title: [My Great Title]) + += Chapter 1 +Content here."#; + + let title = DocumentTitle::from_source(source, "fallback").extract(); + assert_eq!(title, "My Great Title"); + } + + #[test] + fn test_extract_document_title_fallback() { + let source = r#"= Chapter 1 +Content here."#; + + let title = DocumentTitle::from_source(source, "my-chapter").extract(); + assert_eq!(title, "My Chapter"); + } + + #[test] + fn test_extract_document_title_with_markup() { + let source = r#"#set document(title: [Good news about hell - #emph[Severance]])"#; + + let title = DocumentTitle::from_source(source, "fallback").extract(); + // Should strip #emph and underscores + // Note: complex nested bracket handling is limited by regex + assert!(title.contains("Good news")); + assert!(title.contains("Severance")); + } + + #[test] + fn test_extract_document_title_empty() { + let source = r#"#set document(title: []) + +Content"#; + + let title = DocumentTitle::from_source(source, "default-name").extract(); + // Empty title should fall back to filename + assert_eq!(title, "Default Name"); + } + + #[test] + fn test_extract_document_title_complex() { + let source = r#"#set document(title: [Half Loop - _Severance_ [s1/e2]], author: [Test])"#; + + let title = DocumentTitle::from_source(source, "fallback").extract(); + // Should extract title and strip markup + assert!(title.contains("Half Loop")); + assert!(title.contains("Severance")); + } +} diff --git a/crates/core/src/plugins/mod.rs b/crates/core/src/plugins/mod.rs new file mode 100644 index 0000000..f26c650 --- /dev/null +++ b/crates/core/src/plugins/mod.rs @@ -0,0 +1,381 @@ +use crate::config::PluginSection; +use crate::output::OutputConfig; +use crate::project::ProjectConfig; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Trait for managing a running preview server. +pub trait ServerHandle: Send + Sync { + fn url(&self) -> &str; + fn reload(&self); +} + +/// Handle returned by FormatPlugin::open() for managing the opened resource +pub enum OpenHandle { + /// Server-based preview — usable via ServerHandle trait methods. + Server(Box), + /// Direct file open (PDF/EPUB) - fire-and-forget, no reload needed + Direct, + /// No preview capability + None, +} + +use crate::compile::RheoCompileOptions; + +/// Standardized spine options resolved by rheo core before calling compile(). +#[derive(Debug, Clone)] +pub struct SpineOptions { + pub title: Option, + pub vertebrae: Vec, + /// true = merged output, false = per-file output + pub merge: bool, +} + +/// Declares an additional non-Typst input file needed from the project directory. +pub struct PluginInput { + /// Key used to retrieve this input from PluginContext::inputs + pub name: &'static str, + /// Path relative to the project root where the file is expected + pub path: String, + /// If true, a missing file is a compile error; if false, it is absent from ctx.inputs + pub required: bool, +} + +/// Context passed to plugin.compile() for each compilation unit. +pub struct PluginContext<'a> { + pub project: &'a ProjectConfig, + pub output_config: &'a OutputConfig, + pub options: RheoCompileOptions<'a>, + /// Resolved spine options (title, vertebrae, merge flag). + pub spine: SpineOptions, + /// Full parsed plugin section from rheo.toml (or default if not configured). + /// + /// # Reading format-specific configuration + /// + /// Plugins read format-specific fields from `config.extra` using serde JSON patterns: + /// + /// ```ignore + /// // Read a string value + /// let identifier = section.extra.get("identifier") + /// .and_then(|v| v.as_str()) + /// .map(String::from); + /// + /// // Read an array of strings + /// let stylesheets: Vec = section.extra.get("stylesheets") + /// .and_then(|v| v.as_array()) + /// .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + /// .unwrap_or_default(); + /// ``` + pub config: PluginSection, + /// Resolved additional input files declared by the plugin. + /// + /// Paths are relative to the plugin's output directory (e.g., `build/html/`). + /// The CLI copies each declared input from the project root to the output directory + /// before calling `compile()`. + pub inputs: HashMap<&'static str, PathBuf>, +} + +/// Plugin trait for implementing new output formats in rheo. +/// +/// # Implementing a new plugin +/// +/// To add a new output format to rheo: +/// +/// 1. Create a new crate in `crates/` (e.g., `rheo-markdown`) +/// 2. Implement the `FormatPlugin` trait on a zero-sized type: +/// ```ignore +/// pub struct MarkdownPlugin; +/// impl FormatPlugin for MarkdownPlugin { ... } +/// ``` +/// 3. Add the plugin to `all_plugins()` in `crates/cli/src/lib.rs` +/// 4. Add a `[markdown]` section to the rheo.toml configuration reference in CLAUDE.md +/// 5. Document format-specific configuration options in the CLAUDE.md config reference +/// +/// # Plugin lifecycle +/// +/// For each project compilation: +/// +/// 1. **Load config**: rheo.toml is parsed (or defaults are used) +/// 2. **Apply defaults**: `apply_defaults()` is called if no config section exists +/// 3. **Resolve inputs**: Files declared by `inputs()` are copied to output directory +/// 4. **Compile**: `compile()` is called once per file (per-file mode) or once (merged mode) +/// 5. **Open**: `open()` is called if `--open` flag was passed +pub trait FormatPlugin: Send + Sync { + /// Plugin identifier, CLI flag, and output subdirectory name. + /// + /// The return value serves triple duty: + /// + /// 1. **CLI flag**: `--` enables this format (e.g., `--html`) + /// 2. **Output subdirectory**: files are written to `build//` (e.g., `build/html/`) + /// 3. **Format name**: passed to `RheoWorld` for link transformation and `target()` polyfill injection + /// + /// **Requirements:** + /// - Must be stable (do not change between versions) + /// - Must be lowercase + /// - Must be alphanumeric (underscores allowed) + /// - Should be short and descriptive + /// + /// # Examples + /// + /// ```ignore + /// fn name(&self) -> &'static str { + /// "html" // CLI: --html, output: build/html/ + /// } + /// ``` + fn name(&self) -> &'static str; + + /// Whether this plugin merges files by default. + /// + /// Override to return `true` for formats that always produce a single merged output + /// (e.g., EPUB). When `true`, the plugin is called once with all files concatenated; + /// when `false`, the plugin is called once per file. + /// + /// This default can be overridden in rheo.toml via `spine.merge = true`. + /// + /// # Examples + /// + /// ```ignore + /// fn default_merge(&self) -> bool { + /// true // EPUB always merges into a single file + /// } + /// ``` + fn default_merge(&self) -> bool { + false + } + + /// Set plugin-specific smart defaults when no rheo.toml section exists. + /// + /// Called by the CLI after loading a project when the plugin's section is not + /// present in rheo.toml. This allows plugins to infer reasonable defaults (e.g., + /// inferring a title from the project name, setting default stylesheets). + /// + /// The `section` argument is a fresh `PluginSection` with default values; modify + /// it in place to apply your plugin's defaults. + /// + /// # Arguments + /// + /// * `section` - The plugin's section (mutable, modify in place) + /// * `project_name` - Derived project/file name for title inference + /// + /// # Examples + /// + /// ```ignore + /// fn apply_defaults(&self, section: &mut PluginSection, project_name: &str) { + /// let spine = section.spine.get_or_insert_with(|| UniversalSpine { + /// title: None, + /// vertebrae: vec![], + /// merge: None, + /// }); + /// if spine.title.is_none() { + /// spine.title = Some(DocumentTitle::to_readable_name(project_name)); + /// } + /// } + /// ``` + fn apply_defaults(&self, _section: &mut PluginSection, _project_name: &str) {} + + /// Open the output for this format in the appropriate viewer. + /// + /// Called when the user passes `--open` with `rheo watch`. The plugin can: + /// + /// - Start a development server and return `OpenHandle::Server(handle)` — the CLI + /// calls `handle.reload()` after each successful recompile + /// - Open files directly with the system handler and return `OpenHandle::Direct` + /// - Return `OpenHandle::None` if preview is not supported + /// + /// # Arguments + /// + /// * `output_dir` - Path to the format's output directory (e.g., `build/html/`) + /// * `format_name` - The format name (same as `name()`, provided for convenience) + /// + /// # Examples + /// + /// ```ignore + /// fn open(&self, output_dir: &Path, _format_name: &str) -> Result { + /// let runtime = tokio::runtime::Runtime::new()?; + /// let (server_task, reload_tx, url) = runtime.block_on(async { + /// start_server(output_dir.to_path_buf(), 3000).await + /// })?; + /// // ... browser opening logic ... + /// let handle = HtmlServerHandle { runtime, server_task, url, reload_callback }; + /// Ok(OpenHandle::Server(Box::new(handle))) + /// } + /// ``` + fn open(&self, output_dir: &Path, _format_name: &str) -> crate::Result { + crate::open_all_files_in_folder(output_dir.to_path_buf(), self.name())?; + Ok(OpenHandle::Direct) + } + + /// Declare additional non-Typst input files this plugin needs. + /// + /// Returns a list of input files that should be copied from the project root to + /// the plugin's output directory before compilation. This is useful for assets like + /// stylesheets, fonts, or images. + /// + /// # Return value + /// + /// A vector of `PluginInput` declarations. Each declares: + /// - `name`: Key to retrieve the file from `PluginContext::inputs` + /// - `path`: Path relative to `ProjectConfig::root` where the file is expected + /// - `required`: If `true`, missing files cause compilation errors; if `false`, + /// they are simply omitted from `ctx.inputs` + /// + /// # Examples + /// + /// ```ignore + /// fn inputs(&self) -> Vec { + /// vec![ + /// PluginInput { + /// name: "stylesheet", + /// path: "styles/main.css".to_string(), + /// required: false, // Optional — use default if missing + /// }, + /// PluginInput { + /// name: "logo", + /// path: "assets/logo.png".to_string(), + /// required: true, // Required — error if missing + /// }, + /// ] + /// } + /// ``` + /// + /// # Reading inputs in compile() + /// + /// ```ignore + /// fn compile(&self, ctx: PluginContext<'_>) -> Result<()> { + /// if let Some(stylesheet_path) = ctx.inputs.get("stylesheet") { + /// let css = std::fs::read_to_string(stylesheet_path)?; + /// // ... use css ... + /// } + /// Ok(()) + /// } + /// ``` + fn inputs(&self) -> Vec { + vec![] + } + + /// Provide template files for `rheo init` to write to new projects. + /// + /// This method allows plugins to contribute format-specific template files + /// (e.g., CSS for HTML, custom Typst includes) when initializing a new rheo project. + /// + /// # Return value + /// + /// A vector of `(relative_path, content)` tuples where: + /// - `relative_path` is the file path relative to the project root (e.g., `"style.css"`, `"content/example.typ"`) + /// - `content` is the file contents as a static string + /// + /// # Path conflicts + /// + /// If two plugins claim the same `relative_path`, rheo returns an error at init time. + /// Core templates take precedence over plugin templates (plugins can override core paths + /// only if the core explicitly provides empty placeholders). + /// + /// # Examples + /// + /// ```ignore + /// fn init_templates(&self) -> Vec<(&'static str, &'static str)> { + /// vec![ + /// ("style.css", include_str!("templates/style.css")), + /// ("content/html-example.typ", include_str!("templates/example.typ")), + /// ] + /// } + /// ``` + /// + /// # Default implementation + /// + /// Returns an empty vector (no template files contributed). + fn init_templates(&self) -> Vec<(&'static str, &'static str)> { + vec![] + } + + /// Provide Typst library code to inject into all compiled files. + /// + /// This method allows plugins to contribute format-specific Typst functions, + /// variables, and show rules that are automatically available in all user `.typ` files. + /// + /// # Return value + /// + /// - `Some(code)` — Typst code to inject (concatenated with core prelude and other plugin contributions) + /// - `None` — no library code contributed (default) + /// + /// # Injection order + /// + /// Plugin library code is injected after the core rheo prelude but before user code: + /// + /// ```text + /// 1. Target polyfill (for EPUB) + /// 2. Core rheo.typ prelude (rheo-target(), is-rheo-*(), rheo_template) + /// 3. Plugin library code (all plugins concatenated, sorted by plugin name) + /// 4. User file content + /// ``` + /// + /// # Symbol conflicts + /// + /// Rheo does **not** detect symbol conflicts between plugin libraries. Plugins should use + /// prefixed names (e.g., `pdf-lemma()`, `html-toc()`) to avoid collisions. + /// + /// # When to use this + /// + /// - **Do**: Provide format-specific show rules, helper functions, or constants + /// - **Don't**: Duplicate core functionality (use `is-rheo-*()` helpers instead) + /// + /// # Examples + /// + /// ```ignore + /// fn typst_library(&self) -> Option<&'static str> { + /// Some(#let pdf-watermark() = [CONFIDENTIAL]) + /// } + /// ``` + /// + /// # Default implementation + /// + /// Returns `None` (no library code contributed). + fn typst_library(&self) -> Option<&'static str> { + None + } + + /// Compile one file (per-file mode) or merged output (merged mode). + /// + /// This is the core compilation method. The behavior depends on `ctx.spine.merge`: + /// + /// ## Per-file mode (`merge == false`) + /// + /// Called once per `.typ` file in the project. Use `ctx.options.world` to compile: + /// + /// ```ignore + /// let world = ctx.options.world.ok_or_else(|| { + /// RheoError::project_config("plugin requires a world in per-file mode") + /// })?; + /// let result = typst::compile::(world)?; + /// ``` + /// + /// ## Merged mode (`merge == true`) + /// + /// Called once with all files concatenated. The plugin must build its own worlds: + /// + /// ```ignore + /// // ctx.options.world is None — build your own from ctx.options.root + /// let world = RheoWorld::new(ctx.options.root, &concatenated_file, Some("pdf"))?; + /// let result = typst::compile::(&world)?; + /// ``` + /// + /// # The merge ↔ world contract + /// + /// | Mode | `ctx.spine.merge` | `ctx.options.world` | `ctx.options.input` | + /// |------|-------------------|---------------------|---------------------| + /// | Per-file | `false` | `Some(world)` | `Some(path)` | + /// | Merged | `true` | `None` | `None` | + /// + /// Plugins in per-file mode must use the pre-configured world; plugins in merged + /// mode must create their own worlds using `ctx.options.root` as the content root. + /// + /// # Error handling + /// + /// Return errors as `Err(...)` — the CLI records failures and continues with other + /// files/plugins. Do not swallow errors silently. + /// + /// # Arguments + /// + /// * `ctx` - Compilation context with project config, options, spine, inputs, etc. + fn compile(&self, ctx: PluginContext<'_>) -> crate::Result<()>; +} diff --git a/src/rs/project.rs b/crates/core/src/project.rs similarity index 62% rename from src/rs/project.rs rename to crates/core/src/project.rs index faa9766..7013dd3 100644 --- a/src/rs/project.rs +++ b/crates/core/src/project.rs @@ -1,5 +1,3 @@ -use crate::config::EpubSpine; -use crate::formats::pdf::DocumentTitle; use crate::{Result, RheoConfig, RheoError}; use std::path::{Path, PathBuf}; use tracing::debug; @@ -15,7 +13,7 @@ pub enum ProjectMode { } /// Configuration for a Typst project -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ProjectConfig { /// Project name (derived from folder basename) pub name: String, @@ -29,25 +27,17 @@ pub struct ProjectConfig { /// List of .typ files in the project pub typ_files: Vec, - /// Project-specific style.css (for HTML export) if it exists - pub style_css: Option, - /// Compilation mode (directory or single file) pub mode: ProjectMode, /// Path to the config file that was loaded - /// None if using default config (single-file mode without --config) + /// None if using default config (no rheo.toml found) pub config_path: Option, } impl ProjectConfig { - /// Detect project configuration from a path (file or directory) - /// - /// # Arguments - /// * `path` - Path to project directory or single .typ file - /// * `config_path` - Optional path to custom rheo.toml config file + /// Detect project configuration from a path (file or directory). pub fn from_path(path: &Path, config_path: Option<&Path>) -> Result { - // Check if path exists and determine if it's a file or directory let metadata = path .metadata() .map_err(|e| RheoError::path(path, format!("path does not exist: {}", e)))?; @@ -61,9 +51,7 @@ impl ProjectConfig { } } - /// Detect project configuration from a directory path fn from_directory(path: &Path, config_path: Option<&Path>) -> Result { - // Canonicalize the root path for consistent path handling let root = path.canonicalize().map_err(|e| { RheoError::path( path, @@ -71,14 +59,12 @@ impl ProjectConfig { ) })?; - // Extract project name from directory basename let name = root .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| RheoError::project_config("failed to get project name from directory"))? .to_string(); - // Load config: custom path takes precedence let (config, loaded_config_path) = if let Some(custom_path) = config_path { debug!(config = %custom_path.display(), "loading custom config"); let config = RheoConfig::load_from_path(custom_path)?; @@ -94,20 +80,11 @@ impl ProjectConfig { (config, loaded_path) }; - // Apply smart defaults if no config file was loaded - let config = if loaded_config_path.is_none() { - apply_smart_defaults(config, &name, ProjectMode::Directory) - } else { - config - }; - - // Determine search directory: content_dir if configured, otherwise project root let search_dir = config .resolve_content_dir(&root) .unwrap_or_else(|| root.clone()); debug!(search_dir = %search_dir.display(), "searching for .typ files"); - // Find all .typ files in the search directory (recursive walk) let typ_files: Vec = WalkDir::new(&search_dir) .into_iter() .filter_map(|e| e.ok()) @@ -115,33 +92,21 @@ impl ProjectConfig { .map(|e| e.path().to_path_buf()) .collect(); - // Detect optional project-specific resources - let style_css = root.join("style.css"); - let style_css = if style_css.is_file() { - Some(style_css) - } else { - None - }; - Ok(ProjectConfig { name, root, config, typ_files, - style_css, mode: ProjectMode::Directory, config_path: loaded_config_path, }) } - /// Detect project configuration from a single .typ file fn from_single_file(file_path: &Path, config_path: Option<&Path>) -> Result { - // Validate .typ extension if file_path.extension().and_then(|s| s.to_str()) != Some("typ") { return Err(RheoError::path(file_path, "file must have .typ extension")); } - // Canonicalize the file path first (resolves relative to absolute) let file_path = file_path.canonicalize().map_err(|e| { RheoError::path( file_path, @@ -149,83 +114,72 @@ impl ProjectConfig { ) })?; - // Root = parent directory (now guaranteed to be absolute) - let root = file_path + let file_parent = file_path .parent() - .ok_or_else(|| RheoError::path(&file_path, "file has no parent directory"))? - .to_path_buf(); + .ok_or_else(|| RheoError::path(&file_path, "file has no parent directory"))?; - // Project name = file stem let name = file_path .file_stem() .and_then(|s| s.to_str()) .ok_or_else(|| RheoError::path(&file_path, "invalid filename"))? .to_string(); - // Load config if --config provided, otherwise use default - let (config, loaded_config_path) = if let Some(custom_path) = config_path { + let (config, loaded_config_path, root) = if let Some(custom_path) = config_path { debug!(config = %custom_path.display(), "using custom config in single-file mode"); let config = RheoConfig::load_from_path(custom_path)?; - (config, Some(custom_path.to_path_buf())) - } else { - (RheoConfig::default(), None) - }; - - // Apply smart defaults if no config file was loaded - let config = if loaded_config_path.is_none() { - apply_smart_defaults(config, &name, ProjectMode::SingleFile) + // Use the directory containing the custom config as project root + let config_root = custom_path + .parent() + .ok_or_else(|| RheoError::path(custom_path, "config path has no parent"))? + .to_path_buf(); + (config, Some(custom_path.to_path_buf()), config_root) } else { - config + // Walk up from file's parent directory looking for rheo.toml + let mut current_dir = Some(file_parent); + let mut found_config = None; + + // Walk up the directory tree (max 10 levels to avoid infinite loops) + for _ in 0..10 { + if let Some(dir) = current_dir { + let config_candidate = dir.join("rheo.toml"); + if config_candidate.exists() { + debug!( + config = %config_candidate.display(), + "discovered rheo.toml in single-file mode" + ); + let config = RheoConfig::load_from_path(&config_candidate)?; + found_config = Some((config, config_candidate.clone(), dir.to_path_buf())); + break; + } + // Move to parent directory + current_dir = dir.parent(); + } else { + break; + } + } + + if let Some((config, path, config_root)) = found_config { + (config, Some(path), config_root) + } else { + // No config found - use file's parent as root with defaults + debug!("no rheo.toml found in single-file mode, using defaults"); + (RheoConfig::default(), None, file_parent.to_path_buf()) + } }; - // Single file in typ_files list let typ_files = vec![file_path.clone()]; - // Check for optional resources in root directory - let style_css = root.join("style.css"); - let style_css = if style_css.is_file() { - Some(style_css) - } else { - None - }; - Ok(ProjectConfig { name, root, config, typ_files, - style_css, mode: ProjectMode::SingleFile, config_path: loaded_config_path, }) } } -/// Apply smart defaults when no rheo.toml exists. -/// -/// This generates sensible spine configurations for EPUB based on the project -/// mode and name. PDF is not modified to maintain backwards compatibility -/// (users expect per-file PDFs by default). -fn apply_smart_defaults( - mut config: RheoConfig, - project_name: &str, - mode: ProjectMode, -) -> RheoConfig { - // Generate human-readable title from project/file name - let title = Some(DocumentTitle::to_readable_name(project_name)); - - // Apply EPUB defaults if spine not configured - if config.epub.spine.is_none() { - let vertebrae = match mode { - ProjectMode::SingleFile => vec![], // Empty: will auto-discover single file - ProjectMode::Directory => vec!["**/*.typ".to_string()], // All files - }; - config.epub.spine = Some(EpubSpine { title, vertebrae }); - } - - config -} - #[cfg(test)] mod tests { use super::*; @@ -268,10 +222,8 @@ mod tests { } #[test] - fn test_single_file_discovers_assets() { + fn test_single_file_with_assets_in_root() { let temp = TempDir::new().unwrap(); - - // Create assets in parent directory fs::write(temp.path().join("style.css"), "body {}").unwrap(); fs::create_dir(temp.path().join("img")).unwrap(); fs::write(temp.path().join("references.bib"), "@article{}").unwrap(); @@ -280,8 +232,7 @@ mod tests { fs::write(&file, "#heading[Test]").unwrap(); let project = ProjectConfig::from_path(&file, None).unwrap(); - - assert!(project.style_css.is_some()); + assert_eq!(project.root, temp.path().canonicalize().unwrap()); } #[test] @@ -302,22 +253,15 @@ mod tests { let file = temp.path().join("document.typ"); fs::write(&file, "#heading[Test]").unwrap(); - // Save original directory and change to temp directory let original_dir = std::env::current_dir().unwrap(); std::env::set_current_dir(temp.path()).unwrap(); - // Use relative path (no directory component) let result = ProjectConfig::from_path(Path::new("document.typ"), None); - - // Restore original directory std::env::set_current_dir(original_dir).unwrap(); - // Verify it succeeded let project = result.unwrap(); assert_eq!(project.name, "document"); assert_eq!(project.mode, ProjectMode::SingleFile); - assert_eq!(project.typ_files.len(), 1); - assert_eq!(project.root, temp.path().canonicalize().unwrap()); } #[test] @@ -327,44 +271,24 @@ mod tests { fs::write(&file, "#heading[Test]").unwrap(); let project = ProjectConfig::from_path(&file, None).unwrap(); - - // Single-file mode without custom config should have None assert!(project.config_path.is_none()); } #[test] - fn test_single_file_epub_default_merge() { + fn test_no_smart_defaults_applied_in_project() { + // Smart defaults are now applied by plugins in the CLI, not in project loading. + // The project config should have empty plugin_sections when no rheo.toml exists. let temp = TempDir::new().unwrap(); let file = temp.path().join("my-document.typ"); fs::write(&file, "#heading[Test]").unwrap(); let project = ProjectConfig::from_path(&file, None).unwrap(); - - // Should have default spine config for EPUB - assert!(project.config.epub.spine.is_some()); - let merge = project.config.epub.spine.as_ref().unwrap(); - assert_eq!(merge.title.as_ref().unwrap(), "My Document"); - assert!(merge.vertebrae.is_empty()); - } - - #[test] - fn test_directory_epub_default_merge() { - let temp = TempDir::new().unwrap(); - fs::write(temp.path().join("a.typ"), "A").unwrap(); - fs::write(temp.path().join("b.typ"), "B").unwrap(); - - let project = ProjectConfig::from_path(temp.path(), None).unwrap(); - - // Should have default spine config for EPUB - assert!(project.config.epub.spine.is_some()); - let merge = project.config.epub.spine.as_ref().unwrap(); - assert_eq!(merge.vertebrae, vec!["**/*.typ"]); - // Title should be based on temp directory name (will vary) - assert!(merge.title.is_some()); + // No epub section should be present (no rheo.toml) + assert!(!project.config.plugin_sections.contains_key("epub")); } #[test] - fn test_explicit_config_not_modified() { + fn test_explicit_config_loaded() { let temp = TempDir::new().unwrap(); let config_path = temp.path().join("rheo.toml"); fs::write( @@ -378,22 +302,19 @@ mod tests { fs::write(temp.path().join("custom.typ"), "content").unwrap(); let project = ProjectConfig::from_path(temp.path(), None).unwrap(); - - // Should preserve explicit config - let merge = project.config.epub.spine.as_ref().unwrap(); - assert_eq!(merge.title.clone().unwrap(), "Custom Title"); - assert_eq!(merge.vertebrae, vec!["custom.typ"]); + let section = project.config.plugin_section("epub"); + let spine = section.spine.unwrap(); + assert_eq!(spine.title.as_deref().unwrap(), "Custom Title"); + assert_eq!(spine.vertebrae, vec!["custom.typ"]); } #[test] - fn test_pdf_not_auto_merged() { + fn test_pdf_no_spine_by_default() { let temp = TempDir::new().unwrap(); fs::write(temp.path().join("a.typ"), "A").unwrap(); fs::write(temp.path().join("b.typ"), "B").unwrap(); let project = ProjectConfig::from_path(temp.path(), None).unwrap(); - - // PDF should not get default spine config (backwards compatibility) - assert!(project.config.pdf.spine.is_none()); + assert!(project.config.spine_for_plugin("pdf").is_none()); } } diff --git a/src/rs/results.rs b/crates/core/src/results.rs similarity index 54% rename from src/rs/results.rs rename to crates/core/src/results.rs index 22988d6..b599522 100644 --- a/src/rs/results.rs +++ b/crates/core/src/results.rs @@ -1,4 +1,3 @@ -use crate::OutputFormat; use std::collections::HashMap; use tracing::info; @@ -12,7 +11,7 @@ pub struct FormatResult { /// Aggregated compilation results across all output formats #[derive(Debug, Default)] pub struct CompilationResults { - results: HashMap, + results: HashMap, } impl CompilationResults { @@ -23,19 +22,19 @@ impl CompilationResults { } } - /// Record a successful compilation for the given format - pub fn record_success(&mut self, format: OutputFormat) { - self.results.entry(format).or_default().succeeded += 1; + /// Record a successful compilation for the given plugin + pub fn record_success(&mut self, name: &str) { + self.results.entry(name.to_string()).or_default().succeeded += 1; } - /// Record a failed compilation for the given format - pub fn record_failure(&mut self, format: OutputFormat) { - self.results.entry(format).or_default().failed += 1; + /// Record a failed compilation for the given plugin + pub fn record_failure(&mut self, name: &str) { + self.results.entry(name.to_string()).or_default().failed += 1; } - /// Get the result counts for a specific format - pub fn get(&self, format: OutputFormat) -> FormatResult { - self.results.get(&format).copied().unwrap_or_default() + /// Get the result counts for a specific plugin + pub fn get(&self, name: &str) -> FormatResult { + self.results.get(name).copied().unwrap_or_default() } /// Check if any compilations failed @@ -43,20 +42,20 @@ impl CompilationResults { self.results.values().any(|r| r.failed > 0) } - /// Log a summary of compilation results for requested formats - pub fn log_summary(&self, requested_formats: &[OutputFormat]) { - for format in requested_formats { - let result = self.get(*format); + /// Log a summary of compilation results for requested plugins + pub fn log_summary(&self, names: &[&str]) { + for name in names { + let result = self.get(name); let total = result.succeeded + result.failed; if total > 0 { if result.failed == 0 { info!( - format = format!("{:?}", format), + format = *name, "successfully compiled {} file(s)", result.succeeded ); } else { info!( - format = format!("{:?}", format), + format = *name, "compiled {} file(s), {} failed", result.succeeded, result.failed ); } diff --git a/src/rs/reticulate/mod.rs b/crates/core/src/reticulate/mod.rs similarity index 100% rename from src/rs/reticulate/mod.rs rename to crates/core/src/reticulate/mod.rs diff --git a/src/rs/reticulate/parser.rs b/crates/core/src/reticulate/parser.rs similarity index 100% rename from src/rs/reticulate/parser.rs rename to crates/core/src/reticulate/parser.rs diff --git a/src/rs/reticulate/serializer.rs b/crates/core/src/reticulate/serializer.rs similarity index 100% rename from src/rs/reticulate/serializer.rs rename to crates/core/src/reticulate/serializer.rs diff --git a/src/rs/reticulate/spine.rs b/crates/core/src/reticulate/spine.rs similarity index 61% rename from src/rs/reticulate/spine.rs rename to crates/core/src/reticulate/spine.rs index 14d9a6f..3119f5a 100644 --- a/src/rs/reticulate/spine.rs +++ b/crates/core/src/reticulate/spine.rs @@ -1,62 +1,51 @@ -use crate::config::SpineConfig; -use crate::formats::pdf::{DocumentTitle, sanitize_label_name}; -use crate::{OutputFormat, Result, RheoError, TYP_EXT}; -use std::collections::HashSet; +use crate::pdf_utils::{DocumentTitle, sanitize_label_name}; +use crate::plugins::SpineOptions; +use crate::{Result, RheoError, TYP_EXT}; +use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::fs; use std::path::{Path, PathBuf}; use walkdir::WalkDir; -/// A spine with relative linking tranformations +/// A spine with relative linking transformations. #[derive(Debug, Clone)] -pub struct RheoSpine { +pub struct BuiltSpine { /// The name of the file or website that the spine will generate. pub title: Option, /// Whether or not the source has been merged into a single file. - /// This is only false in the case of HTML currently. + /// This is only true for PDF merged mode. pub is_merged: bool, - /// Reticulated (relative link transformed) source files, always of length 1 if `is_merged`. + /// Reticulated (relative link transformed) source files. + /// Always length 1 if `is_merged`. pub source: Vec, } -impl RheoSpine { +impl BuiltSpine { /// Build a RheoSpine with AST-based link transformation for all output formats. /// - /// This unified function handles link transformation for PDF, HTML, and EPUB: - /// - PdfSingle: Removes .typ links, single source, no metadata heading - /// - PdfMerged: Converts .typ links to labels, injects metadata headings, merged into single source - /// - Html: Converts .typ links to .html, multiple sources (one per file), no metadata heading - /// - Epub: Converts .typ links to .xhtml, multiple sources (one per file), no metadata heading - /// /// # Arguments /// * `root` - Project root directory /// * `spine_config` - Optional spine configuration (determines spine files) - /// * `output_format` - Target output format (determines link transformation behavior) - /// - /// # Returns - /// A RheoSpine containing transformed Typst sources ready for compilation. + /// * `format_name` - Target output format name (e.g. "pdf", "html", "epub") + /// * `merge` - Whether to merge spine files into a single source (caller decides) pub fn build( root: &Path, - spine_config: Option<&dyn SpineConfig>, - output_format: OutputFormat, - ) -> Result { - // Generate spine: ordered list of .typ files + spine_config: Option<&SpineOptions>, + format_name: &str, + merge: bool, + ) -> Result { let spine_files = generate_spine(root, spine_config, false)?; - - // Check for duplicate filenames check_duplicate_filenames(&spine_files)?; - // Determine if we should merge sources based on format and config - let should_merge = match output_format { - OutputFormat::Pdf => spine_config.and_then(|s| s.merge()).unwrap_or(false), - OutputFormat::Html | OutputFormat::Epub => false, - }; + // Merge when caller requests it (typically only PDF merged mode). + // Other formats (epub, html) handle concatenation differently. + let should_merge = merge; let mut sources = Vec::new(); for spine_file in &spine_files { - // Read source content let source = fs::read_to_string(spine_file).map_err(|e| { RheoError::project_config(format!( "failed to read spine file '{}': {}", @@ -65,12 +54,10 @@ impl RheoSpine { )) })?; - // Transform links using AST-based transformation let transformed_source = - transform_source(&source, spine_file, &spine_files, output_format, root)?; + transform_source(&source, spine_file, &spine_files, format_name, root)?; - // Add metadata heading only for merged PDF - let final_source = if should_merge && output_format == OutputFormat::Pdf { + let final_source = if should_merge { let (label, doc_title) = extract_label_and_title(&source, spine_file)?; format!( "#metadata(\"{}\") <{}>\n{}\n\n", @@ -83,48 +70,43 @@ impl RheoSpine { sources.push(final_source); } - // Merge sources if needed let final_sources = if should_merge { vec![sources.join("\n\n")] } else { sources }; - // Extract title from spine config - let title = spine_config.and_then(|s| s.title().map(String::from)); + let title = spine_config.and_then(|s| s.title.clone()); - Ok(RheoSpine { + Ok(BuiltSpine { title, is_merged: should_merge, source: final_sources, }) } } -/// Transform source using AST-based link transformation + +/// Transform source using AST-based link transformation. fn transform_source( source: &str, spine_file: &Path, spine_files: &[PathBuf], - output_format: OutputFormat, + format_name: &str, project_root: &Path, ) -> Result { - // Create transformer based on format and mode use crate::reticulate::transformer::LinkTransformer; - let transformer = match (output_format, spine_files.len()) { - (OutputFormat::Pdf, 1) => LinkTransformer::new(output_format), // Single-file PDF - (OutputFormat::Pdf, _) => { - // Merged PDF: pass spine for label references - LinkTransformer::new(output_format).with_spine(spine_files.to_vec()) - } - _ => LinkTransformer::new(output_format), // HTML and EPUB + let transformer = if format_name == "pdf" && spine_files.len() > 1 { + // Merged PDF: pass spine for label references + LinkTransformer::new(format_name).with_spine(spine_files.to_vec()) + } else { + // Single-file PDF, HTML, EPUB, and all other formats + LinkTransformer::new(format_name) }; - // Transform source transformer.transform_source(source, spine_file, project_root) } -/// Extract label and title from source and filename fn extract_label_and_title(source: &str, spine_file: &Path) -> Result<(String, String)> { let filename = spine_file.file_name().ok_or_else(|| { RheoError::project_config(format!( @@ -141,28 +123,24 @@ fn extract_label_and_title(source: &str, spine_file: &Path) -> Result<(String, S Ok((label, title)) } -/// Check for duplicate filenames in spine fn check_duplicate_filenames(spine_files: &[PathBuf]) -> Result<()> { - let mut seen_filenames: HashSet = HashSet::new(); + let mut seen: HashMap = HashMap::new(); for spine_file in spine_files { if let Some(filename) = spine_file.file_name() { - let filename_str = filename.to_string_lossy().to_string(); - - if !seen_filenames.insert(filename_str.clone()) { - // Find the first occurrence - if let Some(first_occurrence) = spine_files.iter().find(|f| { - f.file_name() - .map(|n| n.to_string_lossy() == filename.to_string_lossy()) - .unwrap_or(false) - }) { + let key = filename.to_string_lossy().into_owned(); + match seen.entry(key) { + Entry::Occupied(e) => { return Err(RheoError::project_config(format!( "duplicate filename in spine: '{}' appears at both '{}' and '{}'", - filename_str, - first_occurrence.display(), + filename.to_string_lossy(), + e.get().display(), spine_file.display() ))); } + Entry::Vacant(e) => { + e.insert(spine_file); + } } } } @@ -192,25 +170,32 @@ fn collect_one_typst_file(root: &Path) -> Result> { } } +fn collect_all_typst_files(root: &Path) -> Result> { + let mut typst_files: Vec = WalkDir::new(root) + .into_iter() + .filter_map(|entry| Some(entry.ok()?.path().to_path_buf())) + .filter(|path| { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext == &TYP_EXT[1..]) + .unwrap_or(false) + }) + .collect(); + + if typst_files.is_empty() { + return Err(RheoError::project_config("need at least one .typ file")); + } + + typst_files.sort(); + Ok(typst_files) +} + /// Generates a spine (ordered list of .typ files) based on configuration. -/// -/// # Arguments -/// * `root` - Project root directory -/// * `spine_config` - Optional spine configuration with vertebrae patterns -/// * `require_spine` - If true, spine_config must be provided (PDF mode) -/// -/// # Errors -/// Returns error if: -/// - `require_spine=true` and `spine_config=None` -/// - No .typ files found (fallback mode) -/// - Multiple .typ files found without spine config (fallback mode) -/// - Spine vertebrae matched no .typ files pub fn generate_spine( root: &Path, - spine_config: Option<&dyn SpineConfig>, + spine_config: Option<&SpineOptions>, require_spine: bool, ) -> Result> { - // PDF mode: spine config is required if require_spine && spine_config.is_none() { return Err(RheoError::project_config( "spine configuration required but not provided", @@ -218,18 +203,11 @@ pub fn generate_spine( } match spine_config { - // Single-file mode None => collect_one_typst_file(root), - - // Empty vertebrae pattern: auto-discover single file only - // This is used for single-file mode with default EPUB spine config - Some(spine) if spine.vertebrae().is_empty() => collect_one_typst_file(root), - - // Vertebrae is specified - // Process glob patterns from spine config + Some(spine) if spine.vertebrae.is_empty() => collect_all_typst_files(root), Some(spine) => { let mut typst_files = Vec::new(); - for pattern in spine.vertebrae() { + for pattern in &spine.vertebrae { let glob_pattern = root.join(pattern).display().to_string(); let glob = glob::glob(&glob_pattern).map_err(|e| { RheoError::project_config(format!("invalid glob pattern '{}': {}", pattern, e)) @@ -239,15 +217,11 @@ pub fn generate_spine( .filter_map(|entry| entry.ok()) .filter(|path| path.is_file()) .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("typ")) - .filter(|path| path.file_name().is_some()) // Ensure path has a filename + .filter(|path| path.file_name().is_some()) .collect(); - // Sort lexicographically within each pattern - glob_files.sort_by_cached_key(|p| { - p.file_name() - .expect("file_name() checked in filter above") - .to_os_string() - }); + // Sort by full path (lexicographic) for consistent ordering + glob_files.sort(); typst_files.extend(glob_files); } @@ -265,7 +239,6 @@ pub fn generate_spine( #[cfg(test)] mod tests { use super::*; - use crate::config::{HtmlSpine, PdfSpine}; use std::fs; use tempfile::TempDir; @@ -281,6 +254,14 @@ mod tests { temp } + fn spine_with_vertebrae(vertebrae: Vec) -> SpineOptions { + SpineOptions { + title: Some("Test".to_string()), + vertebrae, + merge: false, + } + } + #[test] fn test_generate_spine_requires_merge_mode() { let temp = create_test_dir_with_files(&["test.typ"]); @@ -331,12 +312,9 @@ mod tests { } #[test] - fn test_generate_spine_with_html_config() { + fn test_generate_spine_with_vertebrae() { let temp = create_test_dir_with_files(&["a.typ", "b.typ", "c.typ"]); - let spine = HtmlSpine { - title: Some("Test".to_string()), - vertebrae: vec!["*.typ".to_string()], - }; + let spine = spine_with_vertebrae(vec!["*.typ".to_string()]); let result = generate_spine(temp.path(), Some(&spine), false); assert!(result.is_ok()); let files = result.unwrap(); @@ -351,22 +329,20 @@ mod tests { "chapters/ch2.typ", "appendix.typ", ]); - let spine = PdfSpine { + let spine = SpineOptions { title: Some("Book".to_string()), vertebrae: vec![ "cover.typ".to_string(), "chapters/*.typ".to_string(), "appendix.typ".to_string(), ], - merge: None, + merge: false, }; let result = generate_spine(temp.path(), Some(&spine), true); assert!(result.is_ok()); let files = result.unwrap(); assert_eq!(files.len(), 4); - // Verify pattern order is preserved assert_eq!(files[0].file_name().unwrap(), "cover.typ"); - // ch1.typ and ch2.typ should be sorted lexicographically within their pattern assert!( files[1] .file_name() @@ -389,11 +365,7 @@ mod tests { #[test] fn test_generate_spine_no_matches_error() { let temp = create_test_dir_with_files(&["readme.md"]); - let spine = PdfSpine { - title: None, - vertebrae: vec!["*.typ".to_string()], - merge: None, - }; + let spine = spine_with_vertebrae(vec!["*.typ".to_string()]); let result = generate_spine(temp.path(), Some(&spine), false); assert!(result.is_err()); assert!( @@ -407,47 +379,20 @@ mod tests { #[test] fn test_generate_spine_empty_pattern_single_file() { let temp = create_test_dir_with_files(&["single.typ"]); - let spine = PdfSpine { - title: Some("Test".to_string()), - vertebrae: vec![], // Empty vertebrae - merge: None, - }; - + let spine = spine_with_vertebrae(vec![]); let result = generate_spine(temp.path(), Some(&spine), false); assert!(result.is_ok()); let files = result.unwrap(); assert_eq!(files.len(), 1); - assert_eq!(files[0].file_name().unwrap(), "single.typ"); } #[test] - fn test_generate_spine_empty_pattern_multiple_files_error() { + fn test_generate_spine_empty_pattern_multiple_files_returns_all() { let temp = create_test_dir_with_files(&["a.typ", "b.typ"]); - let spine = PdfSpine { - title: Some("Test".to_string()), - vertebrae: vec![], // Empty vertebrae with multiple files - merge: None, - }; - + let spine = spine_with_vertebrae(vec![]); let result = generate_spine(temp.path(), Some(&spine), false); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("multiple .typ files") - ); - } - - #[test] - fn test_fallback_lexicographic_ordering() { - let temp = create_test_dir_with_files(&["single.typ"]); - - // Test that fallback with single file works and is ordered - let result = generate_spine(temp.path(), None, false); assert!(result.is_ok()); let files = result.unwrap(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].file_name().unwrap(), "single.typ"); + assert_eq!(files.len(), 2); } } diff --git a/src/rs/reticulate/transformer.rs b/crates/core/src/reticulate/transformer.rs similarity index 69% rename from src/rs/reticulate/transformer.rs rename to crates/core/src/reticulate/transformer.rs index 3ca8fc8..36321da 100644 --- a/src/rs/reticulate/transformer.rs +++ b/crates/core/src/reticulate/transformer.rs @@ -1,51 +1,34 @@ use super::types::{LinkInfo, LinkTransform}; use crate::constants::TYP_EXT; -use crate::formats::pdf::sanitize_label_name; +use crate::pdf_utils::sanitize_label_name; use crate::reticulate::validator::is_relative_typ_link; -use crate::{HTML_EXT, OutputFormat, Result, RheoError, XHTML_EXT}; +use crate::{HTML_EXT, Result, RheoError, XHTML_EXT}; use std::collections::HashMap; use std::ops::Range; use std::path::{Path, PathBuf}; /// Link transformer that converts Typst links to format-specific targets. -/// -/// Encapsulates the logic for transforming links based on output format and -/// project configuration (e.g., merged vs. single-file PDFs). pub struct LinkTransformer { - output_format: OutputFormat, + format_name: String, spine: Option>, } impl LinkTransformer { - /// Create a new LinkTransformer for the specified output format. - pub fn new(format: OutputFormat) -> Self { + /// Create a new LinkTransformer for the specified output format name. + pub(crate) fn new(format_name: &str) -> Self { Self { - output_format: format, + format_name: format_name.to_string(), spine: None, } } /// Set the spine for merged PDF compilation. - /// - /// The spine defines the ordered list of files in a merged PDF, which is used - /// to validate link targets and generate label references. pub fn with_spine(mut self, spine: Vec) -> Self { self.spine = Some(spine); self } /// Transform source code by processing all links. - /// - /// This is the main entry point that combines link extraction, transformation - /// computation, and application. - /// - /// # Arguments - /// * `source` - The source code to transform - /// * `current_file` - The file being transformed (for error messages) - /// * `project_root` - The project root (currently unused but kept for API consistency) - /// - /// # Returns - /// Transformed source code with links converted according to the output format pub fn transform_source( &self, source: &str, @@ -54,17 +37,10 @@ impl LinkTransformer { ) -> Result { use crate::reticulate::{parser, serializer}; - // Extract links from source let source_obj = typst::syntax::Source::detached(source); let links = parser::extract_links(&source_obj); - - // Compute transformations let transformations = self.compute_transformations(&links, current_file)?; - - // Find code block ranges to protect from transformation let code_ranges = serializer::find_code_block_ranges(&source_obj); - - // Apply transformations Ok(serializer::apply_transformations( source, &transformations, @@ -73,10 +49,6 @@ impl LinkTransformer { } /// Compute format-specific transformations for links. - /// - /// Returns a vector of (byte_range, transformation) tuples where: - /// - byte_range: Location of the link in the source - /// - transformation: What to do with the link fn compute_transformations( &self, links: &[LinkInfo], @@ -84,10 +56,8 @@ impl LinkTransformer { ) -> Result, LinkTransform)>> { let mut transformations = Vec::new(); - // For Pdf (merged mode), build a map of filename stems to labels - - let label_map = match (&self.output_format, &self.spine) { - (OutputFormat::Pdf, Some(spine)) => build_label_map(spine), + let label_map: HashMap = match (self.format_name.as_str(), &self.spine) { + ("pdf", Some(spine)) => build_label_map(spine), _ => HashMap::new(), }; @@ -96,17 +66,15 @@ impl LinkTransformer { let filename = extract_filename(url); let stem = filename.strip_suffix(TYP_EXT).unwrap_or(filename); - // Determine transformation based on format and link type let transform = if is_relative_typ_link(url) { - // Relative .typ link transformation according to format - match self.output_format { - OutputFormat::Pdf if self.spine.is_none() => { + match (self.format_name.as_str(), &self.spine) { + ("pdf", None) => { // Single PDF: remove links LinkTransform::Remove { body: link.body.clone(), } } - OutputFormat::Pdf => { + ("pdf", Some(_)) => { // Merged PDF: convert to labels, check if file is in spine if !label_map.contains_key(stem) { return Err(RheoError::project_config(format!( @@ -119,21 +87,17 @@ impl LinkTransformer { new_label: format!("<{}>", label), } } - OutputFormat::Html => { - // HTML: convert .typ to .html - LinkTransform::ReplaceUrl { - new_url: url.replace(TYP_EXT, HTML_EXT), - } - } - OutputFormat::Epub => { - // EPUB: convert .typ to .xhtml - LinkTransform::ReplaceUrl { - new_url: url.replace(TYP_EXT, XHTML_EXT), - } - } + ("html", _) => LinkTransform::ReplaceUrl { + new_url: url.replace(TYP_EXT, HTML_EXT), + }, + ("epub", _) => LinkTransform::ReplaceUrl { + new_url: url.replace(TYP_EXT, XHTML_EXT), + }, + // Unknown formats: passthrough + _ => LinkTransform::KeepOriginal, } } else { - // External URL, fragment, or non-.typ link - always preserve + // External URL, fragment, or non-.typ link — always preserve LinkTransform::KeepOriginal }; @@ -144,10 +108,9 @@ impl LinkTransformer { } } -/// Build a map of filename stems to sanitized labels for merged PDF compilation +/// Build a map of filename stems to sanitized labels for merged PDF compilation. fn build_label_map(spine_files: &[PathBuf]) -> HashMap { let mut map = HashMap::new(); - for spine_file in spine_files { if let Some(filename) = spine_file.file_name() { let filename_str = filename.to_string_lossy(); @@ -156,11 +119,9 @@ fn build_label_map(spine_files: &[PathBuf]) -> HashMap { map.insert(stem.to_string(), label); } } - map } -/// Extract the filename from a path (handles both relative and absolute paths) fn extract_filename(path: &str) -> &str { Path::new(path) .file_name() @@ -185,8 +146,7 @@ mod tests { #[test] fn test_pdf_single_removes_typ_links() { let links = vec![make_link("./file.typ", "text", 0..10)]; - // PDF without spine = single file mode (removes links) - let transformer = LinkTransformer::new(OutputFormat::Pdf); + let transformer = LinkTransformer::new("pdf"); let transforms = transformer .compute_transformations(&links, Path::new("test.typ")) .unwrap(); @@ -205,8 +165,7 @@ mod tests { make_link("http://example.com", "example2", 20..30), make_link("mailto:test@example.com", "email", 40..50), ]; - // PDF without spine = single file mode - let transformer = LinkTransformer::new(OutputFormat::Pdf); + let transformer = LinkTransformer::new("pdf"); let transforms = transformer .compute_transformations(&links, Path::new("test.typ")) .unwrap(); @@ -221,8 +180,7 @@ mod tests { fn test_pdf_merged_converts_to_labels() { let links = vec![make_link("./chapter2.typ", "next", 0..10)]; let spine = vec![PathBuf::from("chapter1.typ"), PathBuf::from("chapter2.typ")]; - // PDF with spine = merged mode (converts to labels) - let transformer = LinkTransformer::new(OutputFormat::Pdf).with_spine(spine); + let transformer = LinkTransformer::new("pdf").with_spine(spine); let transforms = transformer .compute_transformations(&links, Path::new("chapter1.typ")) .unwrap(); @@ -240,8 +198,7 @@ mod tests { fn test_pdf_merged_errors_on_missing_spine_file() { let links = vec![make_link("./missing.typ", "missing", 0..10)]; let spine = vec![PathBuf::from("chapter1.typ")]; - // PDF with spine = merged mode - let transformer = LinkTransformer::new(OutputFormat::Pdf).with_spine(spine); + let transformer = LinkTransformer::new("pdf").with_spine(spine); let result = transformer.compute_transformations(&links, Path::new("chapter1.typ")); assert!(result.is_err()); @@ -259,21 +216,29 @@ mod tests { make_link("./file.typ", "text", 0..10), make_link("https://example.com", "external", 20..30), ]; - let transformer = LinkTransformer::new(OutputFormat::Html); + let transformer = LinkTransformer::new("html"); let transforms = transformer .compute_transformations(&links, Path::new("test.typ")) .unwrap(); assert_eq!(transforms.len(), 2); - // First link (.typ) should be transformed to .html match &transforms[0].1 { LinkTransform::ReplaceUrl { new_url } => assert_eq!(new_url, "./file.html"), _ => panic!("Expected ReplaceUrl transform for .typ link"), } - // Second link (external) should be kept as-is assert!(matches!(transforms[1].1, LinkTransform::KeepOriginal)); } + #[test] + fn test_unknown_format_passthrough() { + let links = vec![make_link("./file.typ", "text", 0..10)]; + let transformer = LinkTransformer::new("unknown"); + let transforms = transformer + .compute_transformations(&links, Path::new("test.typ")) + .unwrap(); + assert!(matches!(transforms[0].1, LinkTransform::KeepOriginal)); + } + #[test] fn test_sanitize_label_name() { assert_eq!(sanitize_label_name("chapter 01"), "chapter_01"); diff --git a/src/rs/reticulate/types.rs b/crates/core/src/reticulate/types.rs similarity index 100% rename from src/rs/reticulate/types.rs rename to crates/core/src/reticulate/types.rs diff --git a/src/rs/reticulate/validator.rs b/crates/core/src/reticulate/validator.rs similarity index 100% rename from src/rs/reticulate/validator.rs rename to crates/core/src/reticulate/validator.rs diff --git a/src/templates/init/content/about.typ b/crates/core/src/templates/init/content/about.typ similarity index 100% rename from src/templates/init/content/about.typ rename to crates/core/src/templates/init/content/about.typ diff --git a/src/templates/init/content/img/header.svg b/crates/core/src/templates/init/content/img/header.svg similarity index 100% rename from src/templates/init/content/img/header.svg rename to crates/core/src/templates/init/content/img/header.svg diff --git a/src/templates/init/content/index.typ b/crates/core/src/templates/init/content/index.typ similarity index 100% rename from src/templates/init/content/index.typ rename to crates/core/src/templates/init/content/index.typ diff --git a/src/templates/init/content/references.bib b/crates/core/src/templates/init/content/references.bib similarity index 100% rename from src/templates/init/content/references.bib rename to crates/core/src/templates/init/content/references.bib diff --git a/src/templates/init/rheo.toml b/crates/core/src/templates/init/rheo.toml similarity index 100% rename from src/templates/init/rheo.toml rename to crates/core/src/templates/init/rheo.toml diff --git a/src/typ/rheo.typ b/crates/core/src/typ/rheo.typ similarity index 85% rename from src/typ/rheo.typ rename to crates/core/src/typ/rheo.typ index 145f252..710c449 100644 --- a/src/typ/rheo.typ +++ b/crates/core/src/typ/rheo.typ @@ -15,12 +15,6 @@ #let is-rheo-html() = "rheo-target" in sys.inputs and sys.inputs.rheo-target == "html" #let is-rheo-pdf() = "rheo-target" in sys.inputs and sys.inputs.rheo-target == "pdf" -#let lemmacount = counter("lemmas") -#let lemma(it) = block(inset: 8pt, [ - #lemmacount.step() - #strong[Lemma #context lemmacount.display()]: #it -]) - #let rheo_template(doc) = context { doc } diff --git a/crates/core/src/typst_types.rs b/crates/core/src/typst_types.rs new file mode 100644 index 0000000..8fc952a --- /dev/null +++ b/crates/core/src/typst_types.rs @@ -0,0 +1,19 @@ +/// Commonly used Typst types re-exported for plugin use. +/// +/// This module re-exports specific Typst types that plugins commonly need +/// for document introspection (e.g., EPUB plugin querying headings). +/// Plugins should import these from rheo_core rather than directly from typst. +// Re-export document type for HTML-based output formats +pub use typst_html::HtmlDocument; + +// Re-export diagnostic string types +pub use typst::diag::{EcoString, eco_format}; + +// Re-export container types +pub use typst::ecow::eco_vec; + +// Re-export foundation types +pub use typst::foundations::{NativeElement, StyleChain}; + +// Re-export model types for document structure +pub use typst::model::{HeadingElem, OutlineNode}; diff --git a/crates/core/src/unified_compile.rs b/crates/core/src/unified_compile.rs new file mode 100644 index 0000000..c9c1963 --- /dev/null +++ b/crates/core/src/unified_compile.rs @@ -0,0 +1,67 @@ +/// Unified compilation interface for rheo plugins. +/// +/// This module provides consistently-named compilation functions at the +/// rheo_core top level, replacing the scattered html_compile and pdf_compile +/// submodules. +use crate::Result; +use std::path::Path; + +// Re-export output types for convenience +pub use typst::layout::PagedDocument; +pub use typst_html::HtmlDocument; + +// Output type aliases for clarity +pub type HtmlString = String; +pub type PdfBytes = Vec; + +// ============================================================================ +// HTML compilation functions +// ============================================================================ + +/// Compile a Typst file to an HTML document. +/// +/// Creates a new RheoWorld for the given input and compiles to HtmlDocument. +pub fn compile_to_html_document( + path: &Path, + root: &Path, + format_name: &str, + plugin_library: Option, +) -> Result { + crate::html_compile::compile_html_to_document(path, root, format_name, plugin_library) +} + +/// Compile using an existing RheoWorld to an HTML document. +pub fn compile_to_html_document_with_world(world: &crate::RheoWorld) -> Result { + crate::html_compile::compile_html_with_world(world) +} + +/// Export an HtmlDocument to an HTML string. +pub fn compile_to_html_string(document: &HtmlDocument) -> Result { + crate::html_compile::compile_document_to_string(document) +} + +// ============================================================================ +// PDF compilation functions +// ============================================================================ + +/// Compile a Typst file to a PDF document. +/// +/// Creates a new RheoWorld for the given input and compiles to PagedDocument. +pub fn compile_to_pdf_document( + path: &Path, + root: &Path, + format_name: Option<&str>, + plugin_library: Option, +) -> Result { + crate::pdf_compile::compile_pdf_to_document(path, root, format_name, plugin_library) +} + +/// Compile using an existing RheoWorld to a PDF document. +pub fn compile_to_pdf_document_with_world(world: &crate::RheoWorld) -> Result { + crate::pdf_compile::compile_pdf_with_world(world) +} + +/// Export a PagedDocument to PDF bytes. +pub fn compile_to_pdf_bytes(document: &PagedDocument) -> Result { + crate::pdf_compile::document_to_pdf_bytes(document) +} diff --git a/crates/core/src/validation.rs b/crates/core/src/validation.rs new file mode 100644 index 0000000..effeaa6 --- /dev/null +++ b/crates/core/src/validation.rs @@ -0,0 +1,183 @@ +use crate::config::Spine; +use crate::manifest_version::ManifestVersion; +use crate::{Result, RheoConfig, RheoError}; +use tracing::warn; + +/// Trait for validating configuration structs after deserialization. +pub trait ValidateConfig { + fn validate(&self) -> Result<()>; +} + +impl ValidateConfig for RheoConfig { + fn validate(&self) -> Result<()> { + // Check version match + let current = ManifestVersion::current(); + if self.version != current { + warn!( + "rheo.toml version {} does not match rheo version {}. \ + Consider updating your rheo.toml version field.", + self.version, current + ); + } + + // Validate every plugin section's spine + for (name, section) in &self.plugin_sections { + if let Some(spine) = §ion.spine { + spine + .validate() + .map_err(|e| RheoError::project_config(format!("[{}]: {}", name, e)))?; + } + } + + Ok(()) + } +} + +/// Validate glob patterns in a vertebrae list. +fn validate_vertebrae(vertebrae: &[String]) -> Result<()> { + for pattern in vertebrae { + glob::Pattern::new(pattern).map_err(|e| { + RheoError::project_config(format!("invalid glob pattern '{}': {}", pattern, e)) + })?; + } + Ok(()) +} + +impl ValidateConfig for Spine { + fn validate(&self) -> Result<()> { + validate_vertebrae(&self.vertebrae)?; + + // A spine with merge=true requires a title + if self.merge == Some(true) && self.title.is_none() { + return Err(RheoError::project_config( + "spine.title is required when merge=true", + )); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_universal_spine_validate_empty() { + let spine = Spine { + title: Some("Test".to_string()), + vertebrae: vec![], + merge: None, + }; + assert!(spine.validate().is_ok()); + } + + #[test] + fn test_universal_spine_validate_valid_patterns() { + let spine = Spine { + title: Some("Test".to_string()), + vertebrae: vec!["*.typ".to_string(), "chapters/**/*.typ".to_string()], + merge: None, + }; + assert!(spine.validate().is_ok()); + } + + #[test] + fn test_universal_spine_validate_invalid_pattern() { + let spine = Spine { + title: Some("Test".to_string()), + vertebrae: vec!["[invalid".to_string()], + merge: None, + }; + let result = spine.validate(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("invalid glob pattern")); + } + + #[test] + fn test_universal_spine_merge_true_requires_title() { + let spine = Spine { + title: None, + vertebrae: vec!["*.typ".to_string()], + merge: Some(true), + }; + let result = spine.validate(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("title is required when merge=true")); + } + + #[test] + fn test_universal_spine_merge_true_with_title_ok() { + let spine = Spine { + title: Some("My Book".to_string()), + vertebrae: vec!["*.typ".to_string()], + merge: Some(true), + }; + assert!(spine.validate().is_ok()); + } + + #[test] + fn test_universal_spine_merge_false_no_title_ok() { + let spine = Spine { + title: None, + vertebrae: vec!["*.typ".to_string()], + merge: Some(false), + }; + assert!(spine.validate().is_ok()); + } + + #[test] + fn test_rheo_config_validates_with_matching_version() { + let toml = format!("version = \"{}\"", env!("CARGO_PKG_VERSION")); + let raw: crate::config::RheoConfigRaw = toml::from_str(&toml).unwrap(); + let config = RheoConfig::try_from(raw).unwrap(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_rheo_config_warns_on_newer_version() { + let toml = r#"version = "99.0.0""#; + let raw: crate::config::RheoConfigRaw = toml::from_str(toml).unwrap(); + let config = RheoConfig::try_from(raw).unwrap(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_rheo_config_warns_on_older_version() { + let toml = r#"version = "0.0.1""#; + let raw: crate::config::RheoConfigRaw = toml::from_str(toml).unwrap(); + let config = RheoConfig::try_from(raw).unwrap(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_rheo_config_validates_plugin_sections() { + let toml = format!( + "version = \"{}\"\n[pdf.spine]\ntitle = \"Book\"\nvertebrae = [\"*.typ\"]\nmerge = true", + env!("CARGO_PKG_VERSION") + ); + let raw: crate::config::RheoConfigRaw = toml::from_str(&toml).unwrap(); + let config = RheoConfig::try_from(raw).unwrap(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_rheo_config_rejects_invalid_glob_in_section() { + let toml = format!( + "version = \"{}\"\n[pdf.spine]\nvertebrae = [\"[invalid\"]", + env!("CARGO_PKG_VERSION") + ); + let raw: crate::config::RheoConfigRaw = toml::from_str(&toml).unwrap(); + let config = RheoConfig::try_from(raw).unwrap(); + let result = config.validate(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid glob pattern") + ); + } +} diff --git a/src/rs/watch.rs b/crates/core/src/watch.rs similarity index 100% rename from src/rs/watch.rs rename to crates/core/src/watch.rs diff --git a/src/rs/world.rs b/crates/core/src/world.rs similarity index 61% rename from src/rs/world.rs rename to crates/core/src/world.rs index 6bd7194..df98446 100644 --- a/src/rs/world.rs +++ b/crates/core/src/world.rs @@ -2,10 +2,11 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use crate::{OutputFormat, Result, RheoError}; +use crate::{Result, RheoError}; use chrono::{Datelike, Local}; use codespan_reporting::files::{Error as CodespanError, Files}; use parking_lot::Mutex; +use tracing::warn; use typst::diag::{FileError, FileResult}; use typst::foundations::{Bytes, Datetime, Dict, IntoValue}; use typst::syntax::{FileId, Lines, Source, VirtualPath}; @@ -18,56 +19,32 @@ use typst_kit::package::PackageStorage; use typst_library::{Feature, Features}; /// Build sys.inputs Dict for Typst compilation. -/// -/// This creates the dictionary that's accessible via `sys.inputs` in Typst code. -/// For EPUB/HTML/PDF compilation, we pass `{"rheo-target": "epub"|"html"|"pdf"}` -/// so user code can detect the output format using: -/// `if "rheo-target" in sys.inputs { sys.inputs.rheo-target }` -fn build_inputs(output_format: Option) -> Dict { +fn build_inputs(format_name: Option<&str>) -> Dict { let mut dict = Dict::new(); - if let Some(format) = output_format { - let format_str = match format { - OutputFormat::Pdf => "pdf", - OutputFormat::Html => "html", - OutputFormat::Epub => "epub", - }; - dict.insert("rheo-target".into(), format_str.into_value()); + if let Some(name) = format_name { + dict.insert("rheo-target".into(), name.into_value()); } dict } /// A simple World implementation for rheo compilation. pub struct RheoWorld { - /// The root directory for resolving imports (document directory). root: PathBuf, - - /// The main file to compile. main: FileId, - - /// Typst's standard library. library: LazyHash, - - /// Metadata about discovered fonts. book: LazyHash, - - /// Locations of and storage for lazily loaded fonts. fonts: Vec, - - /// Maps file ids to source files. slots: Mutex>, - - /// Package storage for downloading and caching packages. package_storage: PackageStorage, - - /// Output format for link transformations (None = no transformation). - output_format: Option, + /// Output format name for link transformations and polyfill injection. + /// None = no transformation. + format_name: Option, + /// Plugin-contributed Typst library code, injected after core prelude. + plugin_library: Option, } -/// Holds the processed data for a file ID. struct FileSlot { - /// The loaded source file (for .typ files). source: Option, - /// The loaded binary data (for other files). file: Option, } @@ -75,11 +52,16 @@ impl RheoWorld { /// Create a new world for compiling the given file. /// /// # Arguments - /// * `root` - The root directory for resolving imports (document directory) + /// * `root` - The root directory for resolving imports /// * `main_file` - The main .typ file to compile - /// * `output_format` - Output format for link transformations (None = no transformation) - pub fn new(root: &Path, main_file: &Path, output_format: Option) -> Result { - // Resolve paths + /// * `format_name` - Plugin name for link transformations (e.g. "pdf", "html", "epub"; None = no transformation) + /// * `plugin_library` - Optional plugin-contributed Typst library code to inject after core prelude + pub fn new( + root: &Path, + main_file: &Path, + format_name: Option<&str>, + plugin_library: Option, + ) -> Result { let root = root.canonicalize().map_err(|e| { RheoError::path( root, @@ -93,31 +75,26 @@ impl RheoWorld { ) })?; - // Create virtual path for main file let main_vpath = VirtualPath::within_root(&main_path, &root).ok_or_else(|| { RheoError::path(&main_path, "main file must be within root directory") })?; let main = FileId::new(None, main_vpath); - // Build library with HTML feature enabled and sys.inputs for format detection let features: Features = [Feature::Html].into_iter().collect(); - let inputs = build_inputs(output_format); + let inputs = build_inputs(format_name); let library = Library::builder() .with_features(features) .with_inputs(inputs) .build(); - // Search for fonts using typst-kit - // Respect TYPST_IGNORE_SYSTEM_FONTS for test consistency let include_system_fonts = std::env::var("TYPST_IGNORE_SYSTEM_FONTS").is_err(); let font_search = Fonts::searcher() .include_system_fonts(include_system_fonts) .search(); - // Create package storage with default paths and downloader let package_storage = PackageStorage::new( - None, // Use default cache directory - None, // Use default data directory + None, + None, Downloader::new(concat!("rheo/", env!("CARGO_PKG_VERSION"))), ); @@ -129,29 +106,17 @@ impl RheoWorld { fonts: font_search.fonts, slots: Mutex::new(HashMap::new()), package_storage, - output_format, + format_name: format_name.map(str::to_string), + plugin_library, }) } /// Reset the file cache for incremental compilation. - /// - /// This clears the cached source files and binary files, forcing them to be - /// reloaded on the next access. Fonts, library, and package storage are preserved. - /// - /// This should be called before each recompilation in watch mode to ensure - /// changed files are picked up while allowing Typst's comemo system to cache - /// compilation results based on the actual file contents. pub fn reset(&self) { self.slots.lock().clear(); } /// Change the main file for this world. - /// - /// This allows reusing the same World instance to compile different files - /// in watch mode, which is more efficient than creating a new World for each file. - /// - /// # Arguments - /// * `main_file` - The new main .typ file to compile pub fn set_main(&mut self, main_file: &Path) -> Result<()> { let main_path = main_file.canonicalize().map_err(|e| { RheoError::path( @@ -168,71 +133,61 @@ impl RheoWorld { Ok(()) } - /// Transform links in source text based on output format. - /// - /// Applies AST-based link transformations: - /// - HTML: .typ → .html - /// - EPUB: .typ → .xhtml - /// - PDF: Removes .typ links (or converts to labels if spine is provided) - /// - /// # Arguments - /// * `text` - Source text to transform - /// * `id` - File ID (for error reporting and path context) - /// * `format` - Output format to transform for - /// - /// # Returns - /// * `FileResult` - Transformed source text - fn transform_links(&self, text: &str, id: FileId, format: &OutputFormat) -> FileResult { + /// Transform links in source text based on output format name. + fn transform_links(&self, text: &str, id: FileId, format_name: &str) -> FileResult { use crate::reticulate::transformer::LinkTransformer; - let transformer = LinkTransformer::new(*format); + let transformer = LinkTransformer::new(format_name); transformer .transform_source(text, id.vpath().as_rootless_path(), &self.root) .map_err(|e| FileError::Other(Some(e.to_string().into()))) } - /// Get the absolute path for a file ID. fn path_for_id(&self, id: FileId) -> FileResult { - // Special handling for stdin (which we don't support) if id.vpath().as_rooted_path().starts_with("<") { return Err(FileError::NotFound( id.vpath().as_rooted_path().display().to_string().into(), )); } - // Handle package imports let mut root = &self.root; let buf; if let Some(spec) = id.package() { - // Download and prepare the package if needed buf = self .package_storage .prepare_package(spec, &mut PrintDownload::new(spec))?; root = &buf; } - // Construct path relative to root (or package root) let path = id.vpath().resolve(root).ok_or_else(|| { FileError::NotFound(id.vpath().as_rooted_path().display().to_string().into()) })?; - // If the file doesn't exist at the resolved location, try the document directory - // This handles cases where templates in subdirectories (or packages) reference - // user files that are in the document root (like references.bib) if !path.exists() { - // Try resolving relative to document root + // Fallback 1: Resolve against project root instead of package root. + // Handles the case where a file path in the project is incorrectly + // specified as a package path, or package resolution fails. if let Some(doc_path) = id.vpath().resolve(&self.root) && doc_path.exists() { return Ok(doc_path); } - // If still not found, try just the filename in the document root - // This handles "./references.bib" in lib/template.typ referring to ../references.bib + // Fallback 2 (last resort): Look for just the filename at project root. + // This strips all directory components and can silently load the wrong + // file if the intended file doesn't exist. For example, if importing + // `chapters/intro.typ` fails but `intro.typ` exists at root, this will + // load the wrong file. if let Some(filename) = id.vpath().as_rooted_path().file_name() { let filename_path = self.root.join(filename); if filename_path.exists() { + // Log a warning so this fallback is visible in verbose mode + warn!( + requested = %id.vpath().as_rooted_path().display(), + loaded = %filename_path.display(), + "path resolution fallback: using filename from project root" + ); return Ok(filename_path); } } @@ -241,33 +196,17 @@ impl RheoWorld { Ok(path) } - /// Look up the lines of a source file. - /// - /// This is used by the codespan-reporting integration to provide source - /// context when displaying diagnostics. Returns the lines from either the - /// cached source or loaded file bytes. - /// - /// Fallback strategy: - /// 1. Check source cache (fastest, already parsed) - /// 2. Load via World trait (handles file I/O and parsing) - /// 3. Try bytes cache and convert (for non-text files like images) - /// 4. Return empty Lines (prevents panic when file unavailable) pub fn lookup(&self, id: FileId) -> Lines { - // Fallback 1: Check source cache (already parsed, fastest path) if let Some(slot) = self.slots.lock().get(&id) && let Some(source) = &slot.source { return source.lines().clone(); } - // Fallback 2: Load source using World trait (handles file I/O and UTF-8 decoding) if let Ok(source) = World::source(self, id) { return source.lines().clone(); } - // Fallback 3: Try bytes cache and convert to Lines - // This handles cases where we have raw bytes but not parsed source - // (e.g., binary files or files that failed source parsing) if let Some(slot) = self.slots.lock().get(&id) && let Some(bytes) = &slot.file && let Ok(lines) = Lines::try_from(bytes) @@ -275,8 +214,6 @@ impl RheoWorld { return lines; } - // Fallback 4: Return empty Lines to prevent panic - // Occurs when file doesn't exist or all loading attempts failed Lines::new(String::new()) } @@ -299,49 +236,42 @@ impl World for RheoWorld { } fn source(&self, id: FileId) -> FileResult { - // Check cache first if let Some(slot) = self.slots.lock().get(&id) && let Some(source) = &slot.source { return Ok(source.clone()); } - // Load from file system let path = self.path_for_id(id)?; let mut text = fs::read_to_string(&path).map_err(|e| FileError::from_io(e, &path))?; - // Inject target() polyfill into ALL .typ files for EPUB compilation - // This shadows the built-in target() to check sys.inputs.rheo-target first, - // allowing user code to use `if target() == "epub"` naturally. - // Packages can also adopt this pattern, or use sys.inputs directly. - let target_polyfill = if matches!(self.output_format, Some(OutputFormat::Epub)) { + // Inject target() polyfill for all plugin formats. + let target_polyfill = if self.format_name.is_some() { "// Polyfill target() to return rheo's output format from sys.inputs\n\ #let target() = if \"rheo-target\" in sys.inputs { sys.inputs.rheo-target } else { std.target() }\n\n" } else { "" }; - // For the main file, also inject the rheo.typ template + // For the main file, also inject the rheo.typ template and plugin library code. if id == self.main { - let rheo_content = include_str!("../typ/rheo.typ"); + let rheo_content = include_str!("typ/rheo.typ"); + let plugin_lib_content = self.plugin_library.as_deref().unwrap_or(""); let template_inject = format!( - "{}{}\n#show: rheo_template\n\n", - target_polyfill, rheo_content + "{}{}\n{}\n#show: rheo_template\n\n", + target_polyfill, rheo_content, plugin_lib_content ); text = format!("{}{}", template_inject, text); } else if !target_polyfill.is_empty() { - // For all other files (local modules and packages), just inject the target polyfill text = format!("{}{}", target_polyfill, text); } - // Apply link transformations for ALL .typ files if output format is set - if let Some(format) = &self.output_format { - text = self.transform_links(&text, id, format)?; + // Apply link transformations for ALL .typ files if output format is set. + if let Some(ref name) = self.format_name { + text = self.transform_links(&text, id, name)?; } let source = Source::new(id, text); - - // Cache the source self.slots.lock().entry(id).or_insert_with(|| FileSlot { source: Some(source.clone()), file: None, @@ -351,20 +281,16 @@ impl World for RheoWorld { } fn file(&self, id: FileId) -> FileResult { - // Check cache first if let Some(slot) = self.slots.lock().get(&id) && let Some(file) = &slot.file { return Ok(file.clone()); } - // Load from file system let path = self.path_for_id(id)?; let data = fs::read(&path).map_err(|e| FileError::from_io(e, &path))?; - let bytes = Bytes::new(data); - // Cache the file self.slots.lock().entry(id).or_insert_with(|| FileSlot { source: None, file: Some(bytes.clone()), @@ -379,8 +305,6 @@ impl World for RheoWorld { fn today(&self, offset: Option) -> Option { let now = Local::now(); - - // The time with the specified UTC offset, or within the local time zone. let with_offset = match offset { None => now, Some(hours) => { @@ -397,10 +321,6 @@ impl World for RheoWorld { } } -/// Implement the Files trait from codespan-reporting for diagnostic rendering. -/// -/// This allows RheoWorld to provide file information (name, source lines, line ranges) -/// to codespan-reporting's diagnostic formatter. impl<'a> Files<'a> for RheoWorld { type FileId = FileId; type Name = String; @@ -409,10 +329,8 @@ impl<'a> Files<'a> for RheoWorld { fn name(&'a self, id: FileId) -> std::result::Result { let vpath = id.vpath(); Ok(if let Some(package) = id.package() { - // For package files, show package name + path format!("{package}{}", vpath.as_rooted_path().display()) } else { - // For local files, try to show relative path from root vpath .resolve(&self.root) .and_then(|abs| pathdiff::diff_paths(abs, &self.root)) @@ -452,7 +370,6 @@ impl<'a> Files<'a> for RheoWorld { } } -/// Progress tracker that logs package downloads using tracing. struct PrintDownload { package_name: String, } diff --git a/crates/epub/Cargo.toml b/crates/epub/Cargo.toml new file mode 100644 index 0000000..f15b2c1 --- /dev/null +++ b/crates/epub/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "rheo-epub" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# Core +rheo-core = { path = "../core", version = "0.2.0" } + +# Error handling +thiserror = { workspace = true } + +# Serialization +serde = { workspace = true } +serde-xml-rs = { workspace = true } +toml = { workspace = true } + +# Date/time +chrono = { workspace = true } + +# EPUB-specific +zip = { workspace = true } +html5ever = { workspace = true } +markup5ever_rcdom = { workspace = true } +iref = { workspace = true } +uuid = { workspace = true } +itertools = { workspace = true } + +# Logging +tracing = { workspace = true } + +# File utilities +tempfile = { workspace = true } diff --git a/src/rs/formats/epub/mod.rs b/crates/epub/src/lib.rs similarity index 51% rename from src/rs/formats/epub/mod.rs rename to crates/epub/src/lib.rs index e887f50..eda5bd2 100644 --- a/src/rs/formats/epub/mod.rs +++ b/crates/epub/src/lib.rs @@ -4,14 +4,16 @@ mod xhtml; use package::{Item, ItemRef, Package}; use xhtml::HtmlInfo; -use crate::compile::RheoCompileOptions; -use crate::config::{EpubConfig, EpubOptions}; -use crate::reticulate::spine::RheoSpine; -use crate::{OutputFormat, Result, RheoError}; -use anyhow::Result as AnyhowResult; use chrono::{DateTime, Utc}; use iref::{IriRef, IriRefBuf, iri::Fragment}; use itertools::Itertools; +use rheo_core::{ + BuiltSpine, FormatPlugin, PluginContext, PluginSection, Result, RheoCompileOptions, RheoError, + Spine, SpineOptions, compile_document_to_string, compile_html_to_document, eco_format, eco_vec, +}; +use rheo_core::{ + DocumentTitle, EcoString, HeadingElem, HtmlDocument, NativeElement, OutlineNode, StyleChain, +}; use std::{ fmt::Write as _, fs::File, @@ -20,15 +22,37 @@ use std::{ path::{Path, PathBuf}, }; use tracing::info; -use typst::{ - diag::{EcoString, eco_format}, - ecow::eco_vec, - foundations::{NativeElement, StyleChain}, - model::{HeadingElem, OutlineNode}, -}; -use typst_html::HtmlDocument; use uuid::Uuid; -use zip::{result::ZipError, write::SimpleFileOptions}; +use zip::write::SimpleFileOptions; + +pub struct EpubPlugin; + +impl FormatPlugin for EpubPlugin { + fn name(&self) -> &'static str { + "epub" + } + + /// EPUB always merges multiple files into a single output. + fn default_merge(&self) -> bool { + true + } + + /// Set EPUB smart defaults: infer spine title from project name when no config exists. + fn apply_defaults(&self, section: &mut PluginSection, project_name: &str) { + let spine = section.spine.get_or_insert_with(|| Spine { + title: None, + vertebrae: vec![], + merge: None, + }); + if spine.title.is_none() { + spine.title = Some(DocumentTitle::to_readable_name(project_name)); + } + } + + fn compile(&self, ctx: PluginContext<'_>) -> Result<()> { + compile_epub_with_spine(&ctx.spine, &ctx.options, &ctx.config) + } +} const CONTAINER_XML: &str = r#" @@ -73,14 +97,11 @@ pub fn generate_nav_xhtml(items: &mut [EpubItem]) -> Result { } let outline = if items.len() == 1 { - // If we only have one item, then its nav is just its outline. items[0] .outline .take() .ok_or_else(|| RheoError::invalid_data("EPUB item missing outline"))? } else { - // If we have multiple items, generate a new level of outline which contains a link - // to each item. items .iter_mut() .map(|item| { @@ -99,7 +120,6 @@ pub fn generate_nav_xhtml(items: &mut [EpubItem]) -> Result { }; stringify_outline(&mut buf, &outline, 12); - buf.push_str(NAV_FOOTER); Ok(buf) } @@ -112,55 +132,53 @@ fn date_format(dt: &DateTime) -> EcoString { } /// Generates the package.opf XML string from the generated EPUB items. -/// -/// See: EPUB 3.3 Package document -pub fn generate_package(items: &[EpubItem], config: &EpubConfig) -> AnyhowResult { +pub fn generate_package( + items: &[EpubItem], + spine: &SpineOptions, + identifier: Option<&str>, + date: Option<&DateTime>, +) -> Result { let info = &items[0].document.info; let language = info.locale.unwrap_or_default().rfc_3066(); - let title = match &config.spine { - None => items[0].title(), - Some(combined) => combined.title.as_ref().unwrap().into(), - }; + let title = spine + .title + .as_deref() + .map(EcoString::from) + .unwrap_or_else(|| items[0].title()); const INTERNAL_UNIQUE_ID: &str = "uid"; - // If the user did not provide a unique ID, we generate a UUID for them. - let identifier_content = match &config.identifier { + let identifier_content = match identifier { Some(id) => id.into(), None => eco_format!("urn:uuid:{}", Uuid::new_v4()), }; - // Start building the package let mut builder = Package::builder(title) .unique_identifier(INTERNAL_UNIQUE_ID) .lang(language.clone()) .identifier(INTERNAL_UNIQUE_ID, identifier_content) .language(language); - // Concatenate all authors into a comma-separated string if !info.author.is_empty() { builder = builder.creator(info.author.join(", ")); } - // Set date if provided - if let Some(ref date) = config.date { - builder = builder.date(date_format(date)); + if let Some(d) = date { + builder = builder.date(date_format(d)); } - // Add metadata elements builder = builder .add_meta("dcterms:modified", date_format(&chrono::Utc::now())) .add_meta("ppub:valid", "."); - // Add navigation item to manifest builder = builder.add_item(Item { id: "nav".into(), - href: IriRefBuf::new("nav.xhtml".into()).unwrap(), + href: IriRefBuf::new("nav.xhtml".into()) + .map_err(|e| RheoError::invalid_data(format!("invalid nav href: {}", e)))?, media_type: XHTML_MEDIATYPE.into(), - properties: Some("nav".into()), // required by spec + properties: Some("nav".into()), }); - // Add all content items to manifest and spine for item in items { let mut prop_list = eco_vec![]; if item.info.scripted { @@ -186,123 +204,163 @@ pub fn generate_package(items: &[EpubItem], config: &EpubConfig) -> AnyhowResult }); } - // Build and validate the package - let package = builder - .build() - .map_err(|e| anyhow::anyhow!("Package validation failed: {}", e))?; + let package = builder.build().map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("Package validation failed: {}", e), + })?; + + let xml = package.to_xml().map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("Package XML generation failed: {}", e), + })?; - Ok(package.to_xml()?) + Ok(xml) } -/// Combines all EPUB components into the final .epub i.e. zip file. -/// -/// See: EPUB 3.3 Open Container Format +/// Combines all EPUB components into the final .epub (zip) file. pub fn zip_epub( epub_path: &Path, package_string: String, nav_xhtml: String, items: &[EpubItem], -) -> AnyhowResult<()> { - let file = File::create(epub_path).map_err(ZipError::Io)?; +) -> Result<()> { + let file = File::create(epub_path).map_err(|e| RheoError::io(e, "creating EPUB file"))?; let file = BufWriter::new(file); let mut zip = zip::ZipWriter::new(file); let opts = SimpleFileOptions::default(); - // The mimetype file must (a) be first in the archive and (b) be stored without compression. zip.start_file( "mimetype", opts.compression_method(zip::CompressionMethod::Stored), - )?; - zip.write_all(EPUB_MEDIATYPE.as_bytes())?; - - // The EPUB root metadata file must be exactly at `META-INF/container.xml`. - // See `CONTAINER_XML` for its pre-baked definition. - zip.add_directory("META-INF", opts)?; - zip.start_file("META-INF/container.xml", opts)?; - zip.write_all(CONTAINER_XML.as_bytes())?; - - // All other files go in the `EPUB` directory (by convention, not standard). - zip.add_directory("EPUB", opts)?; - - zip.start_file("EPUB/package.opf", opts)?; - zip.write_all(package_string.as_bytes())?; - - zip.start_file("EPUB/nav.xhtml", opts)?; - zip.write_all(nav_xhtml.as_bytes())?; + ) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to start mimetype file: {}", e), + })?; + zip.write_all(EPUB_MEDIATYPE.as_bytes()) + .map_err(|e| RheoError::io(e, "writing mimetype"))?; + + zip.add_directory("META-INF", opts) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to add META-INF directory: {}", e), + })?; + zip.start_file("META-INF/container.xml", opts) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to start container.xml: {}", e), + })?; + zip.write_all(CONTAINER_XML.as_bytes()) + .map_err(|e| RheoError::io(e, "writing container.xml"))?; + + zip.add_directory("EPUB", opts) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to add EPUB directory: {}", e), + })?; + + zip.start_file("EPUB/package.opf", opts) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to start package.opf: {}", e), + })?; + zip.write_all(package_string.as_bytes()) + .map_err(|e| RheoError::io(e, "writing package.opf"))?; + + zip.start_file("EPUB/nav.xhtml", opts) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to start nav.xhtml: {}", e), + })?; + zip.write_all(nav_xhtml.as_bytes()) + .map_err(|e| RheoError::io(e, "writing nav.xhtml"))?; for item in items { let filename = format!("EPUB/{}", item.href); - zip.start_file(&filename, opts)?; - zip.write_all(item.xhtml.as_bytes())?; + zip.start_file(&filename, opts) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to start file {}: {}", filename, e), + })?; + zip.write_all(item.xhtml.as_bytes()) + .map_err(|e| RheoError::io(e, format!("writing {}", filename)))?; } - zip.finish()?; - + zip.finish().map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("failed to finish EPUB zip: {}", e), + })?; Ok(()) } -/// Generates a spine from the EPUB configuration using RheoSpine for AST-based -/// link transformation (.typ → .xhtml), compiles each file to XHTML, -/// generates navigation, and packages everything into a .epub (zip) file. -fn compile_epub_impl(config: &EpubConfig, epub_path: &Path, root: &Path) -> Result<()> { - let inner = || -> AnyhowResult<()> { - // Convert spine config to trait object for generic spine handling - let spine_config = config - .spine - .as_ref() - .map(|s| s as &dyn crate::config::SpineConfig); - - // Build RheoSpine with AST-transformed sources (.typ links → .xhtml) - let rheo_spine = RheoSpine::build(root, spine_config, crate::OutputFormat::Epub)?; - - // Get the spine file paths - let spine = crate::reticulate::spine::generate_spine(root, spine_config, false)?; - - // Create EpubItems from transformed sources - let mut items = spine - .iter() - .zip(rheo_spine.source.iter()) - .map(|(path, transformed_source)| { - EpubItem::create_from_source(path.clone(), transformed_source, root) - }) - .collect::>>()?; +fn parse_identifier(section: &PluginSection) -> Option { + section + .extra + .get("identifier") + .and_then(|v| v.as_str()) + .map(String::from) +} - let nav_xhtml = generate_nav_xhtml(&mut items)?; - let package_string = generate_package(&items, config)?; - zip_epub(epub_path, package_string, nav_xhtml, &items) - }; +fn parse_date(section: &PluginSection) -> Option> { + section + .extra + .get("date") + .and_then(|v| v.as_datetime()) + .and_then(|dt| { + chrono::DateTime::parse_from_rfc3339(&dt.to_string()) + .ok() + .map(|d| d.with_timezone(&Utc)) + }) +} - inner().map_err(|e| RheoError::EpubGeneration { - count: 1, - errors: e.to_string(), - })?; +fn compile_epub_impl( + spine: &SpineOptions, + epub_path: &Path, + root: &Path, + identifier: Option<&str>, + date: Option<&DateTime>, +) -> Result<()> { + // Build BuiltSpine with AST-transformed sources (.typ links → .xhtml) + // EPUB handles concatenation itself via create_from_source, so merge=false + let rheo_spine = BuiltSpine::build(root, Some(spine), "epub", false)?; + + // Get the spine file paths + let spine_paths = rheo_core::reticulate::spine::generate_spine(root, Some(spine), false)?; + + let mut items = spine_paths + .iter() + .zip(rheo_spine.source.iter()) + .map(|(path, transformed_source)| { + EpubItem::create_from_source(path.clone(), transformed_source, root) + }) + .collect::>>()?; + + let nav_xhtml = generate_nav_xhtml(&mut items)?; + let package_string = generate_package(&items, spine, identifier, date)?; + zip_epub(epub_path, package_string, nav_xhtml, &items)?; info!(output = %epub_path.display(), "successfully generated EPUB"); Ok(()) } -/// Compile Typst documents to EPUB (unified API). -/// -/// Currently routes to the implementation function. EPUB compilation does not -/// yet support incremental compilation (only fresh compilation is available). -/// -/// # Arguments -/// * `options` - Compilation options (input, output, root, repo_root, world) -/// * `epub_options` - EPUB-specific options (wraps EpubConfig) -/// -/// # Returns -/// * `Result<()>` - Success or compilation error -pub fn compile_epub_new(options: RheoCompileOptions, epub_options: EpubOptions) -> Result<()> { - // Note: EPUB doesn't support incremental compilation yet, so we ignore options.world - // and always do fresh compilation - compile_epub_impl(&epub_options.config, &options.output, &options.root) +/// Compile Typst documents to EPUB using resolved spine options. +fn compile_epub_with_spine( + spine: &SpineOptions, + options: &RheoCompileOptions<'_>, + section: &PluginSection, +) -> Result<()> { + let identifier = parse_identifier(section); + let date = parse_date(section); + compile_epub_impl( + spine, + &options.output, + &options.root, + identifier.as_deref(), + date.as_ref(), + ) } -// ============================================================================ -// EPUB compilation implementation -// ============================================================================ - pub struct EpubItem { href: IriRefBuf, document: HtmlDocument, @@ -312,8 +370,6 @@ pub struct EpubItem { } fn text_to_id(s: &str) -> EcoString { - // TODO: handle all the cases described here: - // https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/ident#syntax s.chars() .map(|char| { if char.is_whitespace() { @@ -326,55 +382,40 @@ fn text_to_id(s: &str) -> EcoString { } impl EpubItem { - pub fn create(path: PathBuf, root: &Path) -> AnyhowResult { - info!(file = %path.display(), "compiling spine file"); - let document = - crate::formats::html::compile_html_to_document(&path, root, OutputFormat::Epub)?; - let parent = path.parent().unwrap(); - let bare_file = path.strip_prefix(parent).unwrap(); - let href = IriRefBuf::new(bare_file.with_extension("xhtml").display().to_string())?; - let (heading_ids, outline) = Self::outline(&document, &href); - // Export to HTML (links already transformed by RheoWorld) - let html_string = crate::formats::html::compile_document_to_string(&document)?; - let (xhtml, info) = xhtml::html_to_portable_xhtml(&html_string, &heading_ids); - - Ok(EpubItem { - href, - document, - xhtml, - info, - outline: Some(outline), - }) - } - - /// Create EpubItem from RheoSpine-transformed source (links already .typ → .xhtml) pub fn create_from_source( path: PathBuf, transformed_source: &str, root: &Path, - ) -> AnyhowResult { - use std::io::Write; - + ) -> Result { info!(file = %path.display(), "compiling spine file with transformed source"); - // Write transformed source to temporary file - let mut temp_file = tempfile::NamedTempFile::new_in(root)?; - temp_file.write_all(transformed_source.as_bytes())?; - temp_file.flush()?; + let mut temp_file = tempfile::NamedTempFile::new_in(root) + .map_err(|e| RheoError::io(e, "creating temp file for EPUB item"))?; + temp_file + .write_all(transformed_source.as_bytes()) + .map_err(|e| RheoError::io(e, "writing transformed source to temp file"))?; + temp_file + .flush() + .map_err(|e| RheoError::io(e, "flushing temp file"))?; let temp_path = temp_file.path(); - - // Compile to HTML document - let document = - crate::formats::html::compile_html_to_document(temp_path, root, OutputFormat::Epub)?; - - let parent = path.parent().unwrap(); - let bare_file = path.strip_prefix(parent).unwrap(); - let href = IriRefBuf::new(bare_file.with_extension("xhtml").display().to_string())?; + let plugin_library = EpubPlugin.typst_library().map(|s| s.to_string()); + let document = compile_html_to_document(temp_path, root, "epub", plugin_library)?; + + let parent = path.parent().ok_or_else(|| { + RheoError::invalid_data(format!("path has no parent: {}", path.display())) + })?; + let bare_file = path + .strip_prefix(parent) + .map_err(|e| RheoError::invalid_data(format!("invalid path prefix: {}", e)))?; + let href = IriRefBuf::new(bare_file.with_extension("xhtml").display().to_string()) + .map_err(|e| RheoError::EpubGeneration { + count: 1, + errors: format!("invalid href for EPUB item: {}", e), + })?; let (heading_ids, outline) = Self::outline(&document, &href); - // Export to HTML (links already .typ → .xhtml from RheoSpine) - let html_string = crate::formats::html::compile_document_to_string(&document)?; + let html_string = compile_document_to_string(&document)?; let (xhtml, info) = xhtml::html_to_portable_xhtml(&html_string, &heading_ids); Ok(EpubItem { @@ -387,7 +428,6 @@ impl EpubItem { } fn outline(doc: &HtmlDocument, href: &IriRef) -> (Vec, Vec>) { - // Adapted from https://github.com/typst/typst/blob/02cd1c13de50363010b41b95148233dc952042c2/crates/typst-pdf/src/outline.rs#L7 let elems = doc.introspector.query(&HeadingElem::ELEM.select()); let (nodes, heading_ids): (Vec<_>, Vec<_>) = elems .iter() @@ -406,9 +446,6 @@ impl EpubItem { None => text, }; let mut anchored_href = href.to_owned(); - // Heading IDs come from either Typst labels or text_to_id(), which should produce - // valid IRI fragments. However, Typst labels could theoretically contain characters - // that require percent-encoding. If this panics, we need to add proper encoding. anchored_href.set_fragment(Some( Fragment::new(&id).expect("heading ID should be a valid IRI fragment"), )); @@ -422,14 +459,11 @@ impl EpubItem { fn title(&self) -> EcoString { match &self.document.info.title { Some(title) => title.clone(), - // Default title must not be empty, so we just use the filename as a fallback None => self.href.path().as_str().into(), } } fn id(&self) -> EcoString { - // Use href as a stand-in for item ID. - // Eg `chapters/foo.typ` becomes `chapters-foo` let mut segments = self.href.path().segments(); let file_name = Path::new(segments.next_back().unwrap().as_str()) .file_stem() diff --git a/src/rs/formats/epub/package.rs b/crates/epub/src/package.rs similarity index 99% rename from src/rs/formats/epub/package.rs rename to crates/epub/src/package.rs index ee214d5..3ed76a9 100644 --- a/src/rs/formats/epub/package.rs +++ b/crates/epub/src/package.rs @@ -4,8 +4,8 @@ //! at the header of each struct. use iref::IriRefBuf; +use rheo_core::typst_types::EcoString; use serde::{Deserialize, Serialize}; -use typst::diag::EcoString; // To understand the idiosyncratic serde renames, see: // https://docs.rs/serde-xml-rs/latest/serde_xml_rs/ @@ -24,7 +24,7 @@ pub struct Package { pub prefix: EcoString, pub metadata: Metadata, pub manifest: Manifest, - pub spine: Spine, + pub spine: ReadingOrder, } impl Package { @@ -185,7 +185,7 @@ impl PackageBuilder { items: self.manifest_items, }; - let spine = Spine { + let spine = ReadingOrder { itemref: self.spine_itemrefs, }; @@ -336,7 +336,7 @@ pub struct Item { /// https://www.w3.org/TR/epub-33/#sec-pkg-spine #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct Spine { +pub struct ReadingOrder { #[serde(default)] pub itemref: Vec, } diff --git a/src/rs/formats/epub/xhtml.rs b/crates/epub/src/xhtml.rs similarity index 99% rename from src/rs/formats/epub/xhtml.rs rename to crates/epub/src/xhtml.rs index f7dc5c5..c039ab8 100644 --- a/src/rs/formats/epub/xhtml.rs +++ b/crates/epub/src/xhtml.rs @@ -2,8 +2,8 @@ use html5ever::{ParseOpts, tendril::TendrilSink}; use markup5ever_rcdom::{Handle, NodeData, RcDom}; +use rheo_core::typst_types::EcoString; use std::{fmt::Write, slice}; -use typst::diag::EcoString; /// Returns true if the given tag name is an HTML void element. /// Void elements are self-closing and cannot have children. diff --git a/crates/html/Cargo.toml b/crates/html/Cargo.toml new file mode 100644 index 0000000..bdd1cd2 --- /dev/null +++ b/crates/html/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "rheo-html" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# Core +rheo-core = { path = "../core", version = "0.2.0" } + +# Error handling +thiserror = { workspace = true } + +# Logging +tracing = { workspace = true } + +# Development server +tokio = { workspace = true } +axum = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +webbrowser = { workspace = true } +tokio-stream = { workspace = true } +mime_guess = { workspace = true } + +# HTML parsing +html5ever = { workspace = true } +markup5ever_rcdom = { workspace = true } diff --git a/src/rs/postprocess/dom.rs b/crates/html/src/dom.rs similarity index 97% rename from src/rs/postprocess/dom.rs rename to crates/html/src/dom.rs index f78a8e8..90ae772 100644 --- a/src/rs/postprocess/dom.rs +++ b/crates/html/src/dom.rs @@ -55,10 +55,7 @@ impl HtmlDom { find_element_by_tag(&self.dom.document, tag_name).map(|handle| Element { handle }) } - /// Get the document root handle. - /// - /// # Returns - /// Reference to the document root handle + #[cfg(test)] pub fn document_root(&self) -> &Handle { &self.dom.document } @@ -116,10 +113,7 @@ impl Element { children.insert(0, child.handle); } - /// Get the tag name of this element. - /// - /// # Returns - /// Tag name as a string slice, or empty string if not an element + #[cfg(test)] pub fn tag_name(&self) -> &str { match &self.handle.data { NodeData::Element { name, .. } => name.local.as_ref(), diff --git a/src/rs/postprocess/html_head.rs b/crates/html/src/html_head.rs similarity index 81% rename from src/rs/postprocess/html_head.rs rename to crates/html/src/html_head.rs index f0546c5..18fec00 100644 --- a/src/rs/postprocess/html_head.rs +++ b/crates/html/src/html_head.rs @@ -25,6 +25,48 @@ use super::dom; /// - HTML parsing fails /// - element is not found /// - HTML serialization fails +/// +/// Embed CSS content directly into the HTML `` as `"); + } + + if let Some(pos) = html.find("") { + let mut result = String::with_capacity(html.len() + styles.len()); + result.push_str(&html[..pos]); + result.push_str(&styles); + result.push_str(&html[pos..]); + Ok(result) + } else { + Err(RheoError::HtmlGeneration { + count: 1, + errors: "HTML document does not contain a element".to_string(), + }) + } +} + pub fn inject_head_links(html: &str, stylesheets: &[&str], fonts: &[&str]) -> Result { // Parse the HTML document let dom = dom::HtmlDom::parse(html)?; diff --git a/crates/html/src/lib.rs b/crates/html/src/lib.rs new file mode 100644 index 0000000..425c401 --- /dev/null +++ b/crates/html/src/lib.rs @@ -0,0 +1,175 @@ +mod dom; +mod html_head; +mod server; + +/// Bundled default HTML stylesheet. +/// Used when the project doesn't provide its own style.css. +pub const DEFAULT_STYLESHEET: &str = include_str!("templates/style.css"); + +use rheo_core::{ + FormatPlugin, OpenHandle, PluginContext, PluginSection, Result, RheoCompileOptions, RheoError, + RheoWorld, ServerHandle, compile_document_to_string, compile_html_with_world, +}; +use std::path::Path; +use tracing::{debug, info, warn}; + +/// Reload callback type - called by watch loop after successful compilation. +/// Defined here because it's only needed by the HTML plugin's development server. +pub type ReloadCallback = Box; + +/// Server handle for HTML plugin's development server +pub struct HtmlServerHandle { + pub runtime: tokio::runtime::Runtime, + pub server_task: tokio::task::JoinHandle<()>, + pub url: String, + pub reload_callback: ReloadCallback, +} + +impl ServerHandle for HtmlServerHandle { + fn url(&self) -> &str { + &self.url + } + fn reload(&self) { + (self.reload_callback)(); + } +} + +/// Format-specific configuration parsed from the `[html]` section of rheo.toml. +struct HtmlConfig { + stylesheets: Vec, + fonts: Vec, +} + +fn parse_html_config(section: &PluginSection) -> HtmlConfig { + let stylesheets = section + .extra + .get("stylesheets") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| vec!["style.css".to_string()]); + let fonts = section + .extra + .get("fonts") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + HtmlConfig { stylesheets, fonts } +} + +pub struct HtmlPlugin; + +impl FormatPlugin for HtmlPlugin { + fn name(&self) -> &'static str { + "html" + } + + fn init_templates(&self) -> Vec<(&'static str, &'static str)> { + vec![("style.css", include_str!("templates/style.css"))] + } + + fn open(&self, output_dir: &Path, _format_name: &str) -> Result { + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| RheoError::io(e, "creating tokio runtime"))?; + + let (server_task, reload_tx, url) = runtime + .block_on(async { server::start_server(output_dir.to_path_buf(), 3000).await })?; + + if let Err(e) = server::open_browser(&url) { + warn!(error = %e, "failed to open browser, but server is running"); + } + + let reload_callback: ReloadCallback = Box::new(move || { + let _ = reload_tx.send(()); + }); + + let handle = HtmlServerHandle { + runtime, + server_task, + url, + reload_callback, + }; + Ok(OpenHandle::Server(Box::new(handle))) + } + + fn compile(&self, ctx: PluginContext<'_>) -> Result<()> { + if ctx.spine.merge { + return Err(RheoError::project_config( + "HTML does not support merged compilation", + )); + } + + let html_config = parse_html_config(&ctx.config); + + // Resolve and read each stylesheet, collecting raw CSS content for inlining. + let mut css_contents: Vec = Vec::new(); + for stylesheet_path in &html_config.stylesheets { + let full_path = ctx.project.root.join(stylesheet_path); + if full_path.exists() { + match std::fs::read_to_string(&full_path) { + Ok(content) => css_contents.push(content), + Err(e) => { + warn!(path = %full_path.display(), error = %e, "failed to read stylesheet, skipping") + } + } + } else if stylesheet_path == "style.css" { + // Default name with no file present: inline the bundled stylesheet. + debug!("using bundled default style.css"); + css_contents.push(DEFAULT_STYLESHEET.to_string()); + } else { + warn!(path = %full_path.display(), "stylesheet not found, skipping"); + } + } + + compile_html_new(ctx.options, &css_contents, &html_config.fonts) + } +} + +fn compile_html_impl( + world: &RheoWorld, + output: &Path, + css_contents: &[String], + fonts: &[String], +) -> Result<()> { + let document = compile_html_with_world(world)?; + + debug!(output = %output.display(), "exporting to HTML"); + let html_string = compile_document_to_string(&document)?; + + // Inject font links first (DOM-based), then inline styles (string-based). + // Ordering matters: string-based injection must run last to avoid re-parsing + // and HTML-escaping CSS content (e.g., `>` in selectors). + let font_refs: Vec<&str> = fonts.iter().map(|s| s.as_str()).collect(); + let html_string = html_head::inject_head_links(&html_string, &[], &font_refs)?; + + let css_refs: Vec<&str> = css_contents.iter().map(|s| s.as_str()).collect(); + let html_string = html_head::inject_inline_styles(&html_string, &css_refs)?; + + debug!(size = html_string.len(), "writing HTML file"); + std::fs::write(output, &html_string) + .map_err(|e| RheoError::io(e, format!("writing HTML file to {:?}", output)))?; + + info!(output = %output.display(), "successfully compiled to HTML"); + Ok(()) +} + +/// Compile Typst document to HTML using an engine-provided World. +pub fn compile_html_new( + options: RheoCompileOptions, + css_contents: &[String], + fonts: &[String], +) -> Result<()> { + let world = options.world.ok_or_else(|| { + RheoError::project_config( + "HTML per-file compile requires a world; this is a rheo bug (internal invariant violation)", + ) + })?; + compile_html_impl(world, &options.output, css_contents, fonts) +} diff --git a/src/rs/server.rs b/crates/html/src/server.rs similarity index 99% rename from src/rs/server.rs rename to crates/html/src/server.rs index ec0568a..550ab8f 100644 --- a/src/rs/server.rs +++ b/crates/html/src/server.rs @@ -1,5 +1,3 @@ -use crate::Result; -use crate::constants::HTML_EXT; use axum::{ Router, body::Body, @@ -8,6 +6,7 @@ use axum::{ response::{IntoResponse, Response, Sse, sse::Event}, routing::get, }; +use rheo_core::{Result, constants::HTML_EXT}; use std::convert::Infallible; use std::net::SocketAddr; use std::path::{Path, PathBuf}; diff --git a/src/templates/init/style.css b/crates/html/src/templates/style.css similarity index 98% rename from src/templates/init/style.css rename to crates/html/src/templates/style.css index acc9874..16758ee 100644 --- a/src/templates/init/style.css +++ b/crates/html/src/templates/style.css @@ -100,7 +100,7 @@ blockquote { hr { height: 1px; - background-color: var(--blockquote-border); + background-color: var(--blockquote-border); border: none; /* removes default border */ } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml new file mode 100644 index 0000000..d93b704 --- /dev/null +++ b/crates/pdf/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "rheo-pdf" +version.workspace = true +edition.workspace = true +authors.workspace = true +description.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +# Core +rheo-core = { path = "../core", version = "0.2.0" } + +# Error handling +thiserror = { workspace = true } + +# Logging +tracing = { workspace = true } + +# File utilities +tempfile = { workspace = true } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs new file mode 100644 index 0000000..6a4d99d --- /dev/null +++ b/crates/pdf/src/lib.rs @@ -0,0 +1,104 @@ +use rheo_core::{ + BuiltSpine, FormatPlugin, PluginContext, Result, RheoError, RheoWorld, SpineOptions, + compile_pdf_to_document, compile_pdf_with_world, document_to_pdf_bytes, +}; +use std::io::Write; +use std::path::Path; +use tempfile::NamedTempFile; +use tracing::{debug, info}; + +pub struct PdfPlugin; + +impl FormatPlugin for PdfPlugin { + fn name(&self) -> &'static str { + "pdf" + } + + fn typst_library(&self) -> Option<&'static str> { + // PDF-specific lemma function for numbered lemmas in academic documents + Some( + r#" +#let lemmacount = counter("lemmas") +#let lemma(it) = block(inset: 8pt, [ + #lemmacount.step() + #strong[Lemma #context lemmacount.display()]: #it +]) +"#, + ) + } + + fn compile(&self, ctx: PluginContext<'_>) -> Result<()> { + if ctx.spine.merge { + compile_pdf_merged_impl(&ctx.spine, &ctx.options.output, &ctx.options.root) + } else { + let world = ctx.options.world.ok_or_else(|| { + RheoError::project_config( + "PDF per-file compile requires a world; this is a rheo bug (internal invariant violation)", + ) + })?; + compile_pdf_single_impl(world, &ctx.options.output) + } + } +} + +fn compile_pdf_single_impl(world: &RheoWorld, output: &Path) -> Result<()> { + let document = compile_pdf_with_world(world)?; + + debug!(output = %output.display(), "exporting to PDF"); + let pdf_bytes = document_to_pdf_bytes(&document)?; + + debug!(size = pdf_bytes.len(), "writing PDF file"); + std::fs::write(output, &pdf_bytes) + .map_err(|e| RheoError::io(e, format!("writing PDF file to {:?}", output)))?; + + info!(output = %output.display(), "successfully compiled to PDF"); + Ok(()) +} + +fn compile_pdf_merged_impl( + spine_config: &SpineOptions, + output_path: &Path, + root: &Path, +) -> Result<()> { + // Build RheoSpine with AST-transformed sources (links → labels, metadata headings injected) + let merge = spine_config.merge; + let rheo_spine = BuiltSpine::build(root, Some(spine_config), "pdf", merge)?; + + debug!(file_count = rheo_spine.source.len(), "built PDF spine"); + + let concatenated_source = &rheo_spine.source[0]; + debug!( + source_length = concatenated_source.len(), + "concatenated sources" + ); + + // Create temporary file with concatenated source in root directory + let mut temp_file = NamedTempFile::new_in(root) + .map_err(|e| RheoError::io(e, "creating temporary file for merged PDF"))?; + temp_file + .write_all(concatenated_source.as_bytes()) + .map_err(|e| RheoError::io(e, "writing concatenated source to temporary file"))?; + temp_file + .flush() + .map_err(|e| RheoError::io(e, "flushing temporary file"))?; + + let temp_path = temp_file.path(); + debug!(temp_path = %temp_path.display(), "created temporary file"); + + // output_format=None because links already transformed to labels by RheoSpine + let plugin_library = PdfPlugin.typst_library().map(|s| s.to_string()); + let document = compile_pdf_to_document(temp_path, root, None, plugin_library)?; + + debug!(output = %output_path.display(), "exporting to PDF"); + let pdf_bytes = document_to_pdf_bytes(&document)?; + + debug!(size = pdf_bytes.len(), "writing PDF file"); + std::fs::write(output_path, &pdf_bytes) + .map_err(|e| RheoError::io(e, format!("writing PDF file to {:?}", output_path)))?; + + info!(output = %output_path.display(), "successfully compiled merged PDF"); + Ok(()) +} + +// Re-export PDF utilities for backwards compatibility +pub use rheo_core::pdf_utils::{DocumentTitle, sanitize_label_name}; diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml new file mode 100644 index 0000000..a4c162b --- /dev/null +++ b/crates/tests/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "rheo-tests" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +test = true + +[dependencies] +# Internal crates +rheo-core = { workspace = true } +rheo-html = { workspace = true } +rheo-pdf = { workspace = true } +rheo-epub = { workspace = true } + +# Workspace dependencies +tempfile = { workspace = true } +walkdir = { workspace = true } +serde = { workspace = true } + +# Test-specific dependencies +ntest = "0.9" +similar = "2.6" +serde_json = "1.0" + +# PDF metadata extraction +lopdf = "0.35" +sha2 = "0.10" + +# EPUB handling +zip = { workspace = true } +html5ever = { workspace = true } +markup5ever_rcdom = { workspace = true } + +# XML parsing (for EPUB metadata) +serde-xml-rs = { workspace = true } diff --git a/tests/README.md b/crates/tests/README.md similarity index 100% rename from tests/README.md rename to crates/tests/README.md diff --git a/tests/cases/code_blocks_with_links/code_examples.typ b/crates/tests/cases/code_blocks_with_links/code_examples.typ similarity index 100% rename from tests/cases/code_blocks_with_links/code_examples.typ rename to crates/tests/cases/code_blocks_with_links/code_examples.typ diff --git a/tests/cases/code_blocks_with_links/rheo.toml b/crates/tests/cases/code_blocks_with_links/rheo.toml similarity index 100% rename from tests/cases/code_blocks_with_links/rheo.toml rename to crates/tests/cases/code_blocks_with_links/rheo.toml diff --git a/tests/cases/cross_directory_links/appendix/notes.typ b/crates/tests/cases/cross_directory_links/appendix/notes.typ similarity index 100% rename from tests/cases/cross_directory_links/appendix/notes.typ rename to crates/tests/cases/cross_directory_links/appendix/notes.typ diff --git a/tests/cases/cross_directory_links/chapters/ch1.typ b/crates/tests/cases/cross_directory_links/chapters/ch1.typ similarity index 100% rename from tests/cases/cross_directory_links/chapters/ch1.typ rename to crates/tests/cases/cross_directory_links/chapters/ch1.typ diff --git a/tests/cases/cross_directory_links/chapters/ch2.typ b/crates/tests/cases/cross_directory_links/chapters/ch2.typ similarity index 100% rename from tests/cases/cross_directory_links/chapters/ch2.typ rename to crates/tests/cases/cross_directory_links/chapters/ch2.typ diff --git a/tests/cases/cross_directory_links/intro.typ b/crates/tests/cases/cross_directory_links/intro.typ similarity index 100% rename from tests/cases/cross_directory_links/intro.typ rename to crates/tests/cases/cross_directory_links/intro.typ diff --git a/tests/cases/cross_directory_links/rheo.toml b/crates/tests/cases/cross_directory_links/rheo.toml similarity index 100% rename from tests/cases/cross_directory_links/rheo.toml rename to crates/tests/cases/cross_directory_links/rheo.toml diff --git a/tests/cases/epub_explicit_spine/chapter.typ b/crates/tests/cases/epub_explicit_spine/chapter.typ similarity index 100% rename from tests/cases/epub_explicit_spine/chapter.typ rename to crates/tests/cases/epub_explicit_spine/chapter.typ diff --git a/tests/cases/epub_explicit_spine/intro.typ b/crates/tests/cases/epub_explicit_spine/intro.typ similarity index 100% rename from tests/cases/epub_explicit_spine/intro.typ rename to crates/tests/cases/epub_explicit_spine/intro.typ diff --git a/tests/cases/epub_explicit_spine/rheo.toml b/crates/tests/cases/epub_explicit_spine/rheo.toml similarity index 100% rename from tests/cases/epub_explicit_spine/rheo.toml rename to crates/tests/cases/epub_explicit_spine/rheo.toml diff --git a/tests/cases/epub_inferred_spine/a.typ b/crates/tests/cases/epub_inferred_spine/a.typ similarity index 100% rename from tests/cases/epub_inferred_spine/a.typ rename to crates/tests/cases/epub_inferred_spine/a.typ diff --git a/tests/cases/epub_inferred_spine/b.typ b/crates/tests/cases/epub_inferred_spine/b.typ similarity index 100% rename from tests/cases/epub_inferred_spine/b.typ rename to crates/tests/cases/epub_inferred_spine/b.typ diff --git a/tests/cases/epub_inferred_spine/c.typ b/crates/tests/cases/epub_inferred_spine/c.typ similarity index 100% rename from tests/cases/epub_inferred_spine/c.typ rename to crates/tests/cases/epub_inferred_spine/c.typ diff --git a/tests/cases/error_formatting/array_index_error.typ b/crates/tests/cases/error_formatting/array_index_error.typ similarity index 100% rename from tests/cases/error_formatting/array_index_error.typ rename to crates/tests/cases/error_formatting/array_index_error.typ diff --git a/tests/cases/error_formatting/function_arg_error.typ b/crates/tests/cases/error_formatting/function_arg_error.typ similarity index 100% rename from tests/cases/error_formatting/function_arg_error.typ rename to crates/tests/cases/error_formatting/function_arg_error.typ diff --git a/tests/cases/error_formatting/import_error.typ b/crates/tests/cases/error_formatting/import_error.typ similarity index 100% rename from tests/cases/error_formatting/import_error.typ rename to crates/tests/cases/error_formatting/import_error.typ diff --git a/tests/cases/error_formatting/invalid_field.typ b/crates/tests/cases/error_formatting/invalid_field.typ similarity index 100% rename from tests/cases/error_formatting/invalid_field.typ rename to crates/tests/cases/error_formatting/invalid_field.typ diff --git a/tests/cases/error_formatting/invalid_method.typ b/crates/tests/cases/error_formatting/invalid_method.typ similarity index 100% rename from tests/cases/error_formatting/invalid_method.typ rename to crates/tests/cases/error_formatting/invalid_method.typ diff --git a/tests/cases/error_formatting/multiple_errors.typ b/crates/tests/cases/error_formatting/multiple_errors.typ similarity index 100% rename from tests/cases/error_formatting/multiple_errors.typ rename to crates/tests/cases/error_formatting/multiple_errors.typ diff --git a/tests/cases/error_formatting/rheo.toml b/crates/tests/cases/error_formatting/rheo.toml similarity index 100% rename from tests/cases/error_formatting/rheo.toml rename to crates/tests/cases/error_formatting/rheo.toml diff --git a/tests/cases/error_formatting/syntax_error.typ b/crates/tests/cases/error_formatting/syntax_error.typ similarity index 100% rename from tests/cases/error_formatting/syntax_error.typ rename to crates/tests/cases/error_formatting/syntax_error.typ diff --git a/tests/cases/error_formatting/type_error.typ b/crates/tests/cases/error_formatting/type_error.typ similarity index 100% rename from tests/cases/error_formatting/type_error.typ rename to crates/tests/cases/error_formatting/type_error.typ diff --git a/tests/cases/error_formatting/undefined_var.typ b/crates/tests/cases/error_formatting/undefined_var.typ similarity index 100% rename from tests/cases/error_formatting/undefined_var.typ rename to crates/tests/cases/error_formatting/undefined_var.typ diff --git a/tests/cases/error_formatting/unknown_function.typ b/crates/tests/cases/error_formatting/unknown_function.typ similarity index 100% rename from tests/cases/error_formatting/unknown_function.typ rename to crates/tests/cases/error_formatting/unknown_function.typ diff --git a/tests/cases/html_spine/about.typ b/crates/tests/cases/html_spine/about.typ similarity index 100% rename from tests/cases/html_spine/about.typ rename to crates/tests/cases/html_spine/about.typ diff --git a/tests/cases/html_spine/index.typ b/crates/tests/cases/html_spine/index.typ similarity index 100% rename from tests/cases/html_spine/index.typ rename to crates/tests/cases/html_spine/index.typ diff --git a/tests/cases/html_spine/rheo.toml b/crates/tests/cases/html_spine/rheo.toml similarity index 100% rename from tests/cases/html_spine/rheo.toml rename to crates/tests/cases/html_spine/rheo.toml diff --git a/tests/cases/link_path_edge_cases/chapter-01.typ b/crates/tests/cases/link_path_edge_cases/chapter-01.typ similarity index 100% rename from tests/cases/link_path_edge_cases/chapter-01.typ rename to crates/tests/cases/link_path_edge_cases/chapter-01.typ diff --git a/tests/cases/link_path_edge_cases/file-name.typ b/crates/tests/cases/link_path_edge_cases/file-name.typ similarity index 100% rename from tests/cases/link_path_edge_cases/file-name.typ rename to crates/tests/cases/link_path_edge_cases/file-name.typ diff --git a/tests/cases/link_path_edge_cases/file_name.typ b/crates/tests/cases/link_path_edge_cases/file_name.typ similarity index 100% rename from tests/cases/link_path_edge_cases/file_name.typ rename to crates/tests/cases/link_path_edge_cases/file_name.typ diff --git a/tests/cases/link_path_edge_cases/main.typ b/crates/tests/cases/link_path_edge_cases/main.typ similarity index 100% rename from tests/cases/link_path_edge_cases/main.typ rename to crates/tests/cases/link_path_edge_cases/main.typ diff --git a/tests/cases/link_path_edge_cases/rheo.toml b/crates/tests/cases/link_path_edge_cases/rheo.toml similarity index 100% rename from tests/cases/link_path_edge_cases/rheo.toml rename to crates/tests/cases/link_path_edge_cases/rheo.toml diff --git a/tests/cases/link_path_edge_cases/version-1.0.typ b/crates/tests/cases/link_path_edge_cases/version-1.0.typ similarity index 100% rename from tests/cases/link_path_edge_cases/version-1.0.typ rename to crates/tests/cases/link_path_edge_cases/version-1.0.typ diff --git a/tests/cases/link_transformation/doc1.typ b/crates/tests/cases/link_transformation/doc1.typ similarity index 100% rename from tests/cases/link_transformation/doc1.typ rename to crates/tests/cases/link_transformation/doc1.typ diff --git a/tests/cases/link_transformation/doc2.typ b/crates/tests/cases/link_transformation/doc2.typ similarity index 100% rename from tests/cases/link_transformation/doc2.typ rename to crates/tests/cases/link_transformation/doc2.typ diff --git a/tests/cases/link_transformation/rheo.toml b/crates/tests/cases/link_transformation/rheo.toml similarity index 100% rename from tests/cases/link_transformation/rheo.toml rename to crates/tests/cases/link_transformation/rheo.toml diff --git a/tests/cases/links_with_fragments/page1.typ b/crates/tests/cases/links_with_fragments/page1.typ similarity index 100% rename from tests/cases/links_with_fragments/page1.typ rename to crates/tests/cases/links_with_fragments/page1.typ diff --git a/tests/cases/links_with_fragments/page2.typ b/crates/tests/cases/links_with_fragments/page2.typ similarity index 100% rename from tests/cases/links_with_fragments/page2.typ rename to crates/tests/cases/links_with_fragments/page2.typ diff --git a/tests/cases/links_with_fragments/rheo.toml b/crates/tests/cases/links_with_fragments/rheo.toml similarity index 100% rename from tests/cases/links_with_fragments/rheo.toml rename to crates/tests/cases/links_with_fragments/rheo.toml diff --git a/tests/cases/multiple_links_inline.typ b/crates/tests/cases/multiple_links_inline.typ similarity index 100% rename from tests/cases/multiple_links_inline.typ rename to crates/tests/cases/multiple_links_inline.typ diff --git a/tests/cases/pdf_individual/chapter1.typ b/crates/tests/cases/pdf_individual/chapter1.typ similarity index 100% rename from tests/cases/pdf_individual/chapter1.typ rename to crates/tests/cases/pdf_individual/chapter1.typ diff --git a/tests/cases/pdf_individual/chapter2.typ b/crates/tests/cases/pdf_individual/chapter2.typ similarity index 100% rename from tests/cases/pdf_individual/chapter2.typ rename to crates/tests/cases/pdf_individual/chapter2.typ diff --git a/tests/cases/pdf_individual/rheo.toml b/crates/tests/cases/pdf_individual/rheo.toml similarity index 100% rename from tests/cases/pdf_individual/rheo.toml rename to crates/tests/cases/pdf_individual/rheo.toml diff --git a/tests/cases/pdf_merge/chapter1.typ b/crates/tests/cases/pdf_merge/chapter1.typ similarity index 100% rename from tests/cases/pdf_merge/chapter1.typ rename to crates/tests/cases/pdf_merge/chapter1.typ diff --git a/tests/cases/pdf_merge/chapter2.typ b/crates/tests/cases/pdf_merge/chapter2.typ similarity index 100% rename from tests/cases/pdf_merge/chapter2.typ rename to crates/tests/cases/pdf_merge/chapter2.typ diff --git a/tests/cases/pdf_merge/conclusion.typ b/crates/tests/cases/pdf_merge/conclusion.typ similarity index 100% rename from tests/cases/pdf_merge/conclusion.typ rename to crates/tests/cases/pdf_merge/conclusion.typ diff --git a/tests/cases/pdf_merge/intro.typ b/crates/tests/cases/pdf_merge/intro.typ similarity index 100% rename from tests/cases/pdf_merge/intro.typ rename to crates/tests/cases/pdf_merge/intro.typ diff --git a/tests/cases/pdf_merge/rheo.toml b/crates/tests/cases/pdf_merge/rheo.toml similarity index 100% rename from tests/cases/pdf_merge/rheo.toml rename to crates/tests/cases/pdf_merge/rheo.toml diff --git a/tests/cases/pdf_merge_false/a.typ b/crates/tests/cases/pdf_merge_false/a.typ similarity index 100% rename from tests/cases/pdf_merge_false/a.typ rename to crates/tests/cases/pdf_merge_false/a.typ diff --git a/tests/cases/pdf_merge_false/b.typ b/crates/tests/cases/pdf_merge_false/b.typ similarity index 100% rename from tests/cases/pdf_merge_false/b.typ rename to crates/tests/cases/pdf_merge_false/b.typ diff --git a/tests/cases/pdf_merge_false/c.typ b/crates/tests/cases/pdf_merge_false/c.typ similarity index 100% rename from tests/cases/pdf_merge_false/c.typ rename to crates/tests/cases/pdf_merge_false/c.typ diff --git a/tests/cases/pdf_merge_false/rheo.toml b/crates/tests/cases/pdf_merge_false/rheo.toml similarity index 100% rename from tests/cases/pdf_merge_false/rheo.toml rename to crates/tests/cases/pdf_merge_false/rheo.toml diff --git a/tests/cases/pdf_spine_merge_false/file1.typ b/crates/tests/cases/pdf_spine_merge_false/file1.typ similarity index 100% rename from tests/cases/pdf_spine_merge_false/file1.typ rename to crates/tests/cases/pdf_spine_merge_false/file1.typ diff --git a/tests/cases/pdf_spine_merge_false/file2.typ b/crates/tests/cases/pdf_spine_merge_false/file2.typ similarity index 100% rename from tests/cases/pdf_spine_merge_false/file2.typ rename to crates/tests/cases/pdf_spine_merge_false/file2.typ diff --git a/tests/cases/pdf_spine_merge_false/rheo.toml b/crates/tests/cases/pdf_spine_merge_false/rheo.toml similarity index 100% rename from tests/cases/pdf_spine_merge_false/rheo.toml rename to crates/tests/cases/pdf_spine_merge_false/rheo.toml diff --git a/tests/cases/relative_path_links/rheo.toml b/crates/tests/cases/relative_path_links/rheo.toml similarity index 100% rename from tests/cases/relative_path_links/rheo.toml rename to crates/tests/cases/relative_path_links/rheo.toml diff --git a/tests/cases/relative_path_links/root.typ b/crates/tests/cases/relative_path_links/root.typ similarity index 100% rename from tests/cases/relative_path_links/root.typ rename to crates/tests/cases/relative_path_links/root.typ diff --git a/tests/cases/relative_path_links/subdir/child.typ b/crates/tests/cases/relative_path_links/subdir/child.typ similarity index 100% rename from tests/cases/relative_path_links/subdir/child.typ rename to crates/tests/cases/relative_path_links/subdir/child.typ diff --git a/tests/cases/relative_path_links/subdir/sibling.typ b/crates/tests/cases/relative_path_links/subdir/sibling.typ similarity index 100% rename from tests/cases/relative_path_links/subdir/sibling.typ rename to crates/tests/cases/relative_path_links/subdir/sibling.typ diff --git a/crates/tests/cases/target_function/main.typ b/crates/tests/cases/target_function/main.typ new file mode 100644 index 0000000..2781d5e --- /dev/null +++ b/crates/tests/cases/target_function/main.typ @@ -0,0 +1,24 @@ +// @rheo:test +// @rheo:formats html,pdf,epub +// @rheo:description Verifies target() function returns correct format string + += Target Function Test + +This test verifies that the `target()` function returns format-specific values. + +#context { + let format = target() + [Current format: *#format*] +} + +== Conditional Content + +#context if target() == "html" { + [HTML-specific content: This appears only in HTML output] +} else if target() == "pdf" { + [PDF-specific content: This appears only in PDF output] +} else if target() == "epub" { + [EPUB-specific content: This appears only in EPUB output] +} else { + [Unknown format detected] +} diff --git a/tests/cases/target_function/rheo.toml b/crates/tests/cases/target_function/rheo.toml similarity index 100% rename from tests/cases/target_function/rheo.toml rename to crates/tests/cases/target_function/rheo.toml diff --git a/crates/tests/cases/target_function_in_module/lib/format_helper.typ b/crates/tests/cases/target_function_in_module/lib/format_helper.typ new file mode 100644 index 0000000..7d6916c --- /dev/null +++ b/crates/tests/cases/target_function_in_module/lib/format_helper.typ @@ -0,0 +1,19 @@ +// Module that uses target() function +// Tests whether target() polyfill propagates to imported files + +#let get_format() = { + target() +} + +#let format_specific_content() = context { + let fmt = target() + if fmt == "epub" { + [Module: EPUB] + } else if fmt == "html" { + [Module: HTML] + } else if fmt == "pdf" { + [Module: PDF] + } else { + [Module: Unknown (#fmt)] + } +} diff --git a/tests/cases/target_function_in_module/main.typ b/crates/tests/cases/target_function_in_module/main.typ similarity index 100% rename from tests/cases/target_function_in_module/main.typ rename to crates/tests/cases/target_function_in_module/main.typ diff --git a/tests/cases/target_function_in_module/rheo.toml b/crates/tests/cases/target_function_in_module/rheo.toml similarity index 100% rename from tests/cases/target_function_in_module/rheo.toml rename to crates/tests/cases/target_function_in_module/rheo.toml diff --git a/tests/cases/target_function_in_package/main.typ b/crates/tests/cases/target_function_in_package/main.typ similarity index 100% rename from tests/cases/target_function_in_package/main.typ rename to crates/tests/cases/target_function_in_package/main.typ diff --git a/tests/cases/target_function_in_package/rheo.toml b/crates/tests/cases/target_function_in_package/rheo.toml similarity index 100% rename from tests/cases/target_function_in_package/rheo.toml rename to crates/tests/cases/target_function_in_package/rheo.toml diff --git a/tests/ref/cases/pdf_merge/html/chapter1.html b/crates/tests/ref/cases/pdf_merge/html/chapter1.html similarity index 100% rename from tests/ref/cases/pdf_merge/html/chapter1.html rename to crates/tests/ref/cases/pdf_merge/html/chapter1.html diff --git a/tests/ref/cases/pdf_merge/html/chapter2.html b/crates/tests/ref/cases/pdf_merge/html/chapter2.html similarity index 100% rename from tests/ref/cases/pdf_merge/html/chapter2.html rename to crates/tests/ref/cases/pdf_merge/html/chapter2.html diff --git a/tests/ref/cases/pdf_merge/html/conclusion.html b/crates/tests/ref/cases/pdf_merge/html/conclusion.html similarity index 100% rename from tests/ref/cases/pdf_merge/html/conclusion.html rename to crates/tests/ref/cases/pdf_merge/html/conclusion.html diff --git a/tests/ref/cases/pdf_merge/html/intro.html b/crates/tests/ref/cases/pdf_merge/html/intro.html similarity index 100% rename from tests/ref/cases/pdf_merge/html/intro.html rename to crates/tests/ref/cases/pdf_merge/html/intro.html diff --git a/tests/ref/cases/pdf_merge/pdf/pdf_merge.metadata.json b/crates/tests/ref/cases/pdf_merge/pdf/pdf_merge.metadata.json similarity index 100% rename from tests/ref/cases/pdf_merge/pdf/pdf_merge.metadata.json rename to crates/tests/ref/cases/pdf_merge/pdf/pdf_merge.metadata.json diff --git a/tests/ref/examples/blog_post/epub/blog_post.metadata.json b/crates/tests/ref/examples/blog_post/epub/blog_post.metadata.json similarity index 100% rename from tests/ref/examples/blog_post/epub/blog_post.metadata.json rename to crates/tests/ref/examples/blog_post/epub/blog_post.metadata.json diff --git a/tests/ref/examples/blog_post/epub/xhtml/portable_epubs.xhtml b/crates/tests/ref/examples/blog_post/epub/xhtml/portable_epubs.xhtml similarity index 100% rename from tests/ref/examples/blog_post/epub/xhtml/portable_epubs.xhtml rename to crates/tests/ref/examples/blog_post/epub/xhtml/portable_epubs.xhtml diff --git a/tests/ref/examples/blog_post/html/portable_epubs.html b/crates/tests/ref/examples/blog_post/html/portable_epubs.html similarity index 100% rename from tests/ref/examples/blog_post/html/portable_epubs.html rename to crates/tests/ref/examples/blog_post/html/portable_epubs.html diff --git a/tests/ref/examples/blog_post/pdf/portable_epubs.metadata.json b/crates/tests/ref/examples/blog_post/pdf/portable_epubs.metadata.json similarity index 100% rename from tests/ref/examples/blog_post/pdf/portable_epubs.metadata.json rename to crates/tests/ref/examples/blog_post/pdf/portable_epubs.metadata.json diff --git a/tests/ref/examples/blog_site/epub/blog_site.metadata.json b/crates/tests/ref/examples/blog_site/epub/blog_site.metadata.json similarity index 100% rename from tests/ref/examples/blog_site/epub/blog_site.metadata.json rename to crates/tests/ref/examples/blog_site/epub/blog_site.metadata.json diff --git a/tests/ref/examples/blog_site/epub/xhtml/severance-ep-1.xhtml b/crates/tests/ref/examples/blog_site/epub/xhtml/severance-ep-1.xhtml similarity index 100% rename from tests/ref/examples/blog_site/epub/xhtml/severance-ep-1.xhtml rename to crates/tests/ref/examples/blog_site/epub/xhtml/severance-ep-1.xhtml diff --git a/tests/ref/examples/blog_site/epub/xhtml/severance-ep-2.xhtml b/crates/tests/ref/examples/blog_site/epub/xhtml/severance-ep-2.xhtml similarity index 100% rename from tests/ref/examples/blog_site/epub/xhtml/severance-ep-2.xhtml rename to crates/tests/ref/examples/blog_site/epub/xhtml/severance-ep-2.xhtml diff --git a/tests/ref/examples/blog_site/epub/xhtml/severance-ep-3.xhtml b/crates/tests/ref/examples/blog_site/epub/xhtml/severance-ep-3.xhtml similarity index 100% rename from tests/ref/examples/blog_site/epub/xhtml/severance-ep-3.xhtml rename to crates/tests/ref/examples/blog_site/epub/xhtml/severance-ep-3.xhtml diff --git a/tests/ref/examples/blog_site/html/index.html b/crates/tests/ref/examples/blog_site/html/index.html similarity index 100% rename from tests/ref/examples/blog_site/html/index.html rename to crates/tests/ref/examples/blog_site/html/index.html diff --git a/tests/ref/examples/blog_site/html/severance-ep-1.html b/crates/tests/ref/examples/blog_site/html/severance-ep-1.html similarity index 100% rename from tests/ref/examples/blog_site/html/severance-ep-1.html rename to crates/tests/ref/examples/blog_site/html/severance-ep-1.html diff --git a/tests/ref/examples/blog_site/html/severance-ep-2.html b/crates/tests/ref/examples/blog_site/html/severance-ep-2.html similarity index 100% rename from tests/ref/examples/blog_site/html/severance-ep-2.html rename to crates/tests/ref/examples/blog_site/html/severance-ep-2.html diff --git a/tests/ref/examples/blog_site/html/severance-ep-3.html b/crates/tests/ref/examples/blog_site/html/severance-ep-3.html similarity index 100% rename from tests/ref/examples/blog_site/html/severance-ep-3.html rename to crates/tests/ref/examples/blog_site/html/severance-ep-3.html diff --git a/tests/ref/examples/blog_site/html/writing-in-typst.html b/crates/tests/ref/examples/blog_site/html/writing-in-typst.html similarity index 100% rename from tests/ref/examples/blog_site/html/writing-in-typst.html rename to crates/tests/ref/examples/blog_site/html/writing-in-typst.html diff --git a/tests/ref/examples/blog_site/pdf/blog_site.metadata.json b/crates/tests/ref/examples/blog_site/pdf/blog_site.metadata.json similarity index 100% rename from tests/ref/examples/blog_site/pdf/blog_site.metadata.json rename to crates/tests/ref/examples/blog_site/pdf/blog_site.metadata.json diff --git a/tests/ref/examples/code_blocks_with_links/pdf/code_examples.metadata.json b/crates/tests/ref/examples/code_blocks_with_links/pdf/code_examples.metadata.json similarity index 100% rename from tests/ref/examples/code_blocks_with_links/pdf/code_examples.metadata.json rename to crates/tests/ref/examples/code_blocks_with_links/pdf/code_examples.metadata.json diff --git a/tests/ref/files/47c9eeba6b52a15f/cover-letter/pdf/cover-letter.metadata.json b/crates/tests/ref/examples/cover_minusletter/pdf/cover-letter.metadata.json similarity index 100% rename from tests/ref/files/47c9eeba6b52a15f/cover-letter/pdf/cover-letter.metadata.json rename to crates/tests/ref/examples/cover_minusletter/pdf/cover-letter.metadata.json diff --git a/crates/tests/ref/examples/cross_directory_links/html/ch1.html b/crates/tests/ref/examples/cross_directory_links/html/ch1.html new file mode 100644 index 0000000..7bc9f7c --- /dev/null +++ b/crates/tests/ref/examples/cross_directory_links/html/ch1.html @@ -0,0 +1,142 @@ + + + + + +

Chapter 1

+

This is the first chapter.

+

References

+

Go back to the introduction.

+

Continue to Chapter 2 (sibling).

+

See the appendix notes for additional info.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/cross_directory_links/html/ch2.html b/crates/tests/ref/examples/cross_directory_links/html/ch2.html new file mode 100644 index 0000000..14bbeaf --- /dev/null +++ b/crates/tests/ref/examples/cross_directory_links/html/ch2.html @@ -0,0 +1,143 @@ + + + + + +

Chapter 2

+

This is the second chapter.

+

Navigation

+

Previous: Chapter 1

+

Root: Introduction

+

Content

+

Testing cross-directory navigation patterns.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/cross_directory_links/html/intro.html b/crates/tests/ref/examples/cross_directory_links/html/intro.html new file mode 100644 index 0000000..4b828ad --- /dev/null +++ b/crates/tests/ref/examples/cross_directory_links/html/intro.html @@ -0,0 +1,141 @@ + + + + + +

Introduction

+

Welcome to the cross-directory test.

+

Overview

+

This document links to Chapter 1.

+

See also Chapter 2 for more details.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/cross_directory_links/html/notes.html b/crates/tests/ref/examples/cross_directory_links/html/notes.html new file mode 100644 index 0000000..7316e15 --- /dev/null +++ b/crates/tests/ref/examples/cross_directory_links/html/notes.html @@ -0,0 +1,143 @@ + + + + + +

Appendix: Notes

+

Additional notes and references.

+

Cross References

+

Back to Chapter 1.

+

Return to the introduction.

+

Details

+

Testing links from a different subdirectory.

+ + + \ No newline at end of file diff --git a/tests/ref/examples/cross_directory_links/pdf/cross_directory_links.metadata.json b/crates/tests/ref/examples/cross_directory_links/pdf/cross_directory_links.metadata.json similarity index 100% rename from tests/ref/examples/cross_directory_links/pdf/cross_directory_links.metadata.json rename to crates/tests/ref/examples/cross_directory_links/pdf/cross_directory_links.metadata.json diff --git a/tests/ref/examples/epub_inferred_spine/epub/epub_inferred_spine.metadata.json b/crates/tests/ref/examples/epub_inferred_spine/epub/epub_inferred_spine.metadata.json similarity index 100% rename from tests/ref/examples/epub_inferred_spine/epub/epub_inferred_spine.metadata.json rename to crates/tests/ref/examples/epub_inferred_spine/epub/epub_inferred_spine.metadata.json diff --git a/tests/ref/examples/epub_inferred_spine/epub/xhtml/a.xhtml b/crates/tests/ref/examples/epub_inferred_spine/epub/xhtml/a.xhtml similarity index 100% rename from tests/ref/examples/epub_inferred_spine/epub/xhtml/a.xhtml rename to crates/tests/ref/examples/epub_inferred_spine/epub/xhtml/a.xhtml diff --git a/tests/ref/examples/epub_inferred_spine/epub/xhtml/b.xhtml b/crates/tests/ref/examples/epub_inferred_spine/epub/xhtml/b.xhtml similarity index 100% rename from tests/ref/examples/epub_inferred_spine/epub/xhtml/b.xhtml rename to crates/tests/ref/examples/epub_inferred_spine/epub/xhtml/b.xhtml diff --git a/tests/ref/examples/epub_inferred_spine/epub/xhtml/c.xhtml b/crates/tests/ref/examples/epub_inferred_spine/epub/xhtml/c.xhtml similarity index 100% rename from tests/ref/examples/epub_inferred_spine/epub/xhtml/c.xhtml rename to crates/tests/ref/examples/epub_inferred_spine/epub/xhtml/c.xhtml diff --git a/tests/ref/examples/epub_inferred_spine/html/a.html b/crates/tests/ref/examples/epub_inferred_spine/html/a.html similarity index 100% rename from tests/ref/examples/epub_inferred_spine/html/a.html rename to crates/tests/ref/examples/epub_inferred_spine/html/a.html diff --git a/tests/ref/examples/epub_inferred_spine/html/b.html b/crates/tests/ref/examples/epub_inferred_spine/html/b.html similarity index 100% rename from tests/ref/examples/epub_inferred_spine/html/b.html rename to crates/tests/ref/examples/epub_inferred_spine/html/b.html diff --git a/tests/ref/examples/epub_inferred_spine/html/c.html b/crates/tests/ref/examples/epub_inferred_spine/html/c.html similarity index 100% rename from tests/ref/examples/epub_inferred_spine/html/c.html rename to crates/tests/ref/examples/epub_inferred_spine/html/c.html diff --git a/tests/ref/examples/epub_inferred_spine/pdf/a.metadata.json b/crates/tests/ref/examples/epub_inferred_spine/pdf/a.metadata.json similarity index 100% rename from tests/ref/examples/epub_inferred_spine/pdf/a.metadata.json rename to crates/tests/ref/examples/epub_inferred_spine/pdf/a.metadata.json diff --git a/tests/ref/examples/epub_inferred_spine/pdf/b.metadata.json b/crates/tests/ref/examples/epub_inferred_spine/pdf/b.metadata.json similarity index 100% rename from tests/ref/examples/epub_inferred_spine/pdf/b.metadata.json rename to crates/tests/ref/examples/epub_inferred_spine/pdf/b.metadata.json diff --git a/tests/ref/examples/epub_inferred_spine/pdf/c.metadata.json b/crates/tests/ref/examples/epub_inferred_spine/pdf/c.metadata.json similarity index 100% rename from tests/ref/examples/epub_inferred_spine/pdf/c.metadata.json rename to crates/tests/ref/examples/epub_inferred_spine/pdf/c.metadata.json diff --git a/crates/tests/ref/examples/index/html/index.html b/crates/tests/ref/examples/index/html/index.html new file mode 100644 index 0000000..aca1fba --- /dev/null +++ b/crates/tests/ref/examples/index/html/index.html @@ -0,0 +1,152 @@ + + + + + +

Screening the subject

+

Screening the subject is a blog that analyses content on both the big and small screen in reasonable detail, i.e. episode-by-episode or scene-by-scene. Contact us at info@ohrg.org for enquiries.

+ +
+


+
+ +
+ + + \ No newline at end of file diff --git a/tests/ref/files/6fdadcdcac7454ad/index/pdf/index.metadata.json b/crates/tests/ref/examples/index/pdf/index.metadata.json similarity index 100% rename from tests/ref/files/6fdadcdcac7454ad/index/pdf/index.metadata.json rename to crates/tests/ref/examples/index/pdf/index.metadata.json diff --git a/crates/tests/ref/examples/link_path_edge_cases/html/chapter-01.html b/crates/tests/ref/examples/link_path_edge_cases/html/chapter-01.html new file mode 100644 index 0000000..6746c30 --- /dev/null +++ b/crates/tests/ref/examples/link_path_edge_cases/html/chapter-01.html @@ -0,0 +1,139 @@ + + + + + +

Chapter 01

+

This filename contains numbers.

+

Back to main.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/link_path_edge_cases/html/file-name.html b/crates/tests/ref/examples/link_path_edge_cases/html/file-name.html new file mode 100644 index 0000000..f235057 --- /dev/null +++ b/crates/tests/ref/examples/link_path_edge_cases/html/file-name.html @@ -0,0 +1,139 @@ + + + + + +

File with Hyphen

+

This filename contains a hyphen.

+

Back to main.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/link_path_edge_cases/html/file_name.html b/crates/tests/ref/examples/link_path_edge_cases/html/file_name.html new file mode 100644 index 0000000..31153c8 --- /dev/null +++ b/crates/tests/ref/examples/link_path_edge_cases/html/file_name.html @@ -0,0 +1,139 @@ + + + + + +

File with Underscore

+

This filename contains an underscore.

+

Back to main.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/link_path_edge_cases/html/main.html b/crates/tests/ref/examples/link_path_edge_cases/html/main.html new file mode 100644 index 0000000..a698cab --- /dev/null +++ b/crates/tests/ref/examples/link_path_edge_cases/html/main.html @@ -0,0 +1,145 @@ + + + + + +

Path Edge Cases Test

+

This tests unusual but valid filename patterns.

+

Links to Edge Case Files

+

Hyphen: file with hyphen

+

Underscore: file with underscore

+

Dot in name: file with dot

+

Number: file with number

+

Content

+

All these edge cases should transform correctly.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/link_path_edge_cases/html/version-1.html b/crates/tests/ref/examples/link_path_edge_cases/html/version-1.html new file mode 100644 index 0000000..df3d188 --- /dev/null +++ b/crates/tests/ref/examples/link_path_edge_cases/html/version-1.html @@ -0,0 +1,139 @@ + + + + + +

Version 1.0

+

This filename contains a dot in the name (not just the extension).

+

Back to main.

+ + + \ No newline at end of file diff --git a/tests/ref/examples/link_path_edge_cases/pdf/link_path_edge_cases.metadata.json b/crates/tests/ref/examples/link_path_edge_cases/pdf/link_path_edge_cases.metadata.json similarity index 100% rename from tests/ref/examples/link_path_edge_cases/pdf/link_path_edge_cases.metadata.json rename to crates/tests/ref/examples/link_path_edge_cases/pdf/link_path_edge_cases.metadata.json diff --git a/tests/ref/examples/link_transformation/epub/link_transformation.metadata.json b/crates/tests/ref/examples/link_transformation/epub/link_transformation.metadata.json similarity index 100% rename from tests/ref/examples/link_transformation/epub/link_transformation.metadata.json rename to crates/tests/ref/examples/link_transformation/epub/link_transformation.metadata.json diff --git a/tests/ref/examples/link_transformation/epub/xhtml/doc1.xhtml b/crates/tests/ref/examples/link_transformation/epub/xhtml/doc1.xhtml similarity index 100% rename from tests/ref/examples/link_transformation/epub/xhtml/doc1.xhtml rename to crates/tests/ref/examples/link_transformation/epub/xhtml/doc1.xhtml diff --git a/tests/ref/examples/link_transformation/epub/xhtml/doc2.xhtml b/crates/tests/ref/examples/link_transformation/epub/xhtml/doc2.xhtml similarity index 100% rename from tests/ref/examples/link_transformation/epub/xhtml/doc2.xhtml rename to crates/tests/ref/examples/link_transformation/epub/xhtml/doc2.xhtml diff --git a/tests/ref/examples/link_transformation/html/doc1.html b/crates/tests/ref/examples/link_transformation/html/doc1.html similarity index 100% rename from tests/ref/examples/link_transformation/html/doc1.html rename to crates/tests/ref/examples/link_transformation/html/doc1.html diff --git a/tests/ref/examples/link_transformation/html/doc2.html b/crates/tests/ref/examples/link_transformation/html/doc2.html similarity index 100% rename from tests/ref/examples/link_transformation/html/doc2.html rename to crates/tests/ref/examples/link_transformation/html/doc2.html diff --git a/tests/ref/examples/link_transformation/pdf/link_transformation.metadata.json b/crates/tests/ref/examples/link_transformation/pdf/link_transformation.metadata.json similarity index 100% rename from tests/ref/examples/link_transformation/pdf/link_transformation.metadata.json rename to crates/tests/ref/examples/link_transformation/pdf/link_transformation.metadata.json diff --git a/tests/ref/examples/links_with_fragments/epub/links_with_fragments.metadata.json b/crates/tests/ref/examples/links_with_fragments/epub/links_with_fragments.metadata.json similarity index 100% rename from tests/ref/examples/links_with_fragments/epub/links_with_fragments.metadata.json rename to crates/tests/ref/examples/links_with_fragments/epub/links_with_fragments.metadata.json diff --git a/tests/ref/examples/links_with_fragments/epub/xhtml/page1.xhtml b/crates/tests/ref/examples/links_with_fragments/epub/xhtml/page1.xhtml similarity index 100% rename from tests/ref/examples/links_with_fragments/epub/xhtml/page1.xhtml rename to crates/tests/ref/examples/links_with_fragments/epub/xhtml/page1.xhtml diff --git a/tests/ref/examples/links_with_fragments/epub/xhtml/page2.xhtml b/crates/tests/ref/examples/links_with_fragments/epub/xhtml/page2.xhtml similarity index 100% rename from tests/ref/examples/links_with_fragments/epub/xhtml/page2.xhtml rename to crates/tests/ref/examples/links_with_fragments/epub/xhtml/page2.xhtml diff --git a/crates/tests/ref/examples/links_with_fragments/html/page1.html b/crates/tests/ref/examples/links_with_fragments/html/page1.html new file mode 100644 index 0000000..b8df11e --- /dev/null +++ b/crates/tests/ref/examples/links_with_fragments/html/page1.html @@ -0,0 +1,142 @@ + + + + + +

Page 1

+

This is the first page.

+

See the introduction in Page 2 for details.

+

Also check the conclusion.

+

Section in Page 1

+

More content here.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/links_with_fragments/html/page2.html b/crates/tests/ref/examples/links_with_fragments/html/page2.html new file mode 100644 index 0000000..9c8890b --- /dev/null +++ b/crates/tests/ref/examples/links_with_fragments/html/page2.html @@ -0,0 +1,146 @@ + + + + + +

Page 2

+

This is the second page.

+

Introduction

+

This is the introduction section.

+

It has some content that the first page links to.

+

Middle Section

+

Some middle content.

+

Conclusion

+

This is the conclusion section.

+

Referenced from page 1.

+ + + \ No newline at end of file diff --git a/tests/ref/examples/links_with_fragments/pdf/links_with_fragments.metadata.json b/crates/tests/ref/examples/links_with_fragments/pdf/links_with_fragments.metadata.json similarity index 100% rename from tests/ref/examples/links_with_fragments/pdf/links_with_fragments.metadata.json rename to crates/tests/ref/examples/links_with_fragments/pdf/links_with_fragments.metadata.json diff --git a/crates/tests/ref/examples/multiple_links_inline/html/multiple_links_inline.html b/crates/tests/ref/examples/multiple_links_inline/html/multiple_links_inline.html new file mode 100644 index 0000000..b413891 --- /dev/null +++ b/crates/tests/ref/examples/multiple_links_inline/html/multiple_links_inline.html @@ -0,0 +1,145 @@ + + + + + +

Multiple Links Test

+

Adjacent Links with Text

+

See File 1 and File 2 for details.

+

Multiple References in List

+

References: A, B, C.

+

Minimal Separation

+

Adjacent links: XY

+

Multiple Links in Sentence

+

Check the introduction, then chapter 1, and finally the conclusion.

+ + + \ No newline at end of file diff --git a/tests/ref/files/f0b104671a707ab2/multiple_links_inline/pdf/multiple_links_inline.metadata.json b/crates/tests/ref/examples/multiple_links_inline/pdf/multiple_links_inline.metadata.json similarity index 100% rename from tests/ref/files/f0b104671a707ab2/multiple_links_inline/pdf/multiple_links_inline.metadata.json rename to crates/tests/ref/examples/multiple_links_inline/pdf/multiple_links_inline.metadata.json diff --git a/tests/ref/examples/pdf_individual/pdf/chapter1.metadata.json b/crates/tests/ref/examples/pdf_individual/pdf/chapter1.metadata.json similarity index 100% rename from tests/ref/examples/pdf_individual/pdf/chapter1.metadata.json rename to crates/tests/ref/examples/pdf_individual/pdf/chapter1.metadata.json diff --git a/tests/ref/examples/pdf_individual/pdf/chapter2.metadata.json b/crates/tests/ref/examples/pdf_individual/pdf/chapter2.metadata.json similarity index 100% rename from tests/ref/examples/pdf_individual/pdf/chapter2.metadata.json rename to crates/tests/ref/examples/pdf_individual/pdf/chapter2.metadata.json diff --git a/crates/tests/ref/examples/pdf_merge_false/html/a.html b/crates/tests/ref/examples/pdf_merge_false/html/a.html new file mode 100644 index 0000000..d1d1aee --- /dev/null +++ b/crates/tests/ref/examples/pdf_merge_false/html/a.html @@ -0,0 +1,139 @@ + + + + doc1 + + +

A

+

The first doc.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/pdf_merge_false/html/c.html b/crates/tests/ref/examples/pdf_merge_false/html/c.html new file mode 100644 index 0000000..3867acb --- /dev/null +++ b/crates/tests/ref/examples/pdf_merge_false/html/c.html @@ -0,0 +1,139 @@ + + + + doc2 + + +

C

+

The second doc.

+ + + \ No newline at end of file diff --git a/tests/ref/examples/pdf_merge_false/pdf/a.metadata.json b/crates/tests/ref/examples/pdf_merge_false/pdf/a.metadata.json similarity index 100% rename from tests/ref/examples/pdf_merge_false/pdf/a.metadata.json rename to crates/tests/ref/examples/pdf_merge_false/pdf/a.metadata.json diff --git a/tests/ref/examples/pdf_merge_false/pdf/c.metadata.json b/crates/tests/ref/examples/pdf_merge_false/pdf/c.metadata.json similarity index 100% rename from tests/ref/examples/pdf_merge_false/pdf/c.metadata.json rename to crates/tests/ref/examples/pdf_merge_false/pdf/c.metadata.json diff --git a/crates/tests/ref/examples/portable_epubs/html/portable_epubs.html b/crates/tests/ref/examples/portable_epubs/html/portable_epubs.html new file mode 100644 index 0000000..32cf710 --- /dev/null +++ b/crates/tests/ref/examples/portable_epubs/html/portable_epubs.html @@ -0,0 +1,348 @@ + + + + Portable EPUBs + + +
+

Portable EPUBs

+ Will CrichtonBrown UniversityJanuary 25, 2024Despite decades of advances in document rendering technology, most of the world’s documents are stuck in the 1990s due to the limitations of PDF. Yet, modern document formats like HTML have yet to provide a competitive alternative to PDF. This post explores what prevents HTML documents from being portable, and I propose a way forward based on the EPUB format. To demonstrate my ideas, this post is presented using a prototype EPUB reading system. +
+

The Good and Bad of PDF

+

PDF is the de facto file format for reading and sharing digital documents like papers, textbooks, and flyers. People use the PDF format for several reasons:

+
    +
  • +

    PDFs are self-contained. A PDF is a single file that contains all the images, fonts, and other data needed to render it. It’s easy to pass around a PDF. A PDF is unlikely to be missing some critical dependency on your computer.

    +
  • +
  • +

    PDFs are rendered consistently. A PDF specifies precisely how it should be rendered, so a PDF author can be confident that a reader will see the same document under any conditions.

    +
  • +
  • +

    PDFs are stable over time. PDFs from decades ago still render the same today. PDFs have a relatively stable standard. PDFs cannot be easily edited.

    +
  • +
+

Yet, in the 32 years since the initial release of PDF, a lot has changed. People print out documents less and less. People use phones, tablets, and e-readers to read digital documents. The internet happened; web browsers now provide a platform for rendering rich documents. These changes have laid bare the limitations of PDF:

+
    +
  • +

    PDFs cannot easily adapt to different screen sizes. Most PDFs are designed to mimic 8.5x11″ paper (or worse, 145,161 km2). These PDFs are readable on a computer monitor, but they are less readable on a tablet, and far less readable on a phone.

    +
  • +
  • +

    PDFs cannot be easily understood by programs. A plain PDF is just a scattered sequence of lines and characters. For accessibility, screen readers may not know which order to read through the text. For data extraction, scraping tables out of a PDF is an open area of research.

    +
  • +
  • +

    PDFs cannot easily express interaction. PDFs were primarily designed as static documents that cannot react to user input beyond filling in forms.

    +
  • +
+

These pros and cons can be traced back to one key fact: the PDF representation of a document is fundamentally unstructured. A PDF consists of commands like:

+
+
Move the cursor to the right by 0.5 inches.
Set the current font color to black.
Draw the text "Hello World" at the current position.
+
+

PDF commands are unstructured because a document’s organization is only clear to a person looking at the rendered document, and not clear from the commands themselves. Reflowing, accessibility, data extraction, and interaction all rely on programmatically understanding the structure of a document. Hence, these aspects are not easy to integrate with PDFs.

+

This raises the question: how can we design digital documents with the benefits of PDFs but without the limitations?

+

Can’t We Just Fix PDF?

+

A simple answer is to improve the PDF format. After all, we already have billions of PDFs — why reinvent the wheel?

+

The designers of PDF are well aware of its limitations. I carefully hedged each bullet with “easily”, because PDF does make it possible to overcome each limitation, at least partially. PDFs can be annotated with their logical structure to create a tagged PDF. Most PDF exporters will not add tags automatically — the simplest option is to use Adobe’s subscription-only Acrobat Pro, which provides an “Automatically tag PDF” action. For example, here is a recent paper of mine with added tags:

+
+ +
Figure 1: A LaTeX-generated paper with automatically added tags.
+
+

If you squint, you can see that the logical structure closely resembles the HTML document model. The document has sections, headings, paragraphs, and links. Adobe characterizes the logical structure as an accessibility feature, but it has other benefits. You may be surprised to know that Adobe Acrobat allows you to reflow tagged PDFs at different screen sizes. You may be unsurprised to know that reflowing does not always work well. For example:

+
+
+ +
Figure 3: A section of the paper in its default fixed layout. Note that the second paragraph is wrapped around the code snippet.
+
+
+ +
Figure 4: The same section of the paper after reflowing to a smaller width. Note that the code is now interleaved with the second paragraph.
+
+
+

In theory, these issues could be fixed. If the world’s PDF exporters could be modified to include logical structure. If Adobe’s reflowing algorithm could be improved to fix its edge cases. If the reflowing algorithm could be specified, and if Adobe were willing to release it publicly, and if it were implemented in each PDF viewer. And that doesn’t even cover interaction! So in practice, I don’t think we can just fix the PDF format, at least within a reasonable time frame.

+

The Good and Bad of HTML

+

In the meantime, we already have a structured document format which can be flexibly and interactively rendered: HTML (and CSS and Javascript, but here just collectively referred to as HTML). The HTML format provides almost exactly the inverse advantages and disadvantages of PDF.

+
    +
  • HTML can more easily adapt to different screen sizes. Over the last 20 years, web developers and browser vendors have created a wide array of techniques for responsive design.
  • +
  • HTML can be more easily understood by a program. HTML provides both an inherent structure plus additional attributes to support accessibility tools.
  • +
  • HTML can more easily express interaction. People have used HTML to produce amazing interactive documents that would be impossible in PDF. Think: Distill.pub, Explorable Explanations, Bartosz Ciechanowski, and Bret Victor, just to name a few.
  • +
+

Again, these advantages are hedged with “more easily”. One can easily produce a convoluted or inaccessible HTML document. But on balance, these aspects are more true than not compared to PDF. However, HTML is lacking where PDF shines:

+
    +
  • HTML is not self-contained. HTML files may contain URL references to external files that may be hosted on a server. One can rarely download an HTML file and have it render correctly without an internet connection.
  • +
  • HTML is not always rendered consistently. HTML’s dynamic layout means that an author may not see the same document as a reader. Moreover, HTML layout is not fully specified, so browsers may differ in their implementation.
  • +
  • HTML is not fully stable over time. Browsers try to maintain backwards compatibility (come on and slam!), but the HTML format is still evolving. The HTML standard is a “living standard” due to the rapidly changing needs and feature sets of modern browsers.
  • +
+

So I’ve been thinking: how can we design HTML documents to gain the benefits of PDFs without losing the key strengths of HTML? The rest of this document will present some early prototypes and tentative proposals in this direction.

+

Self-Contained HTML with EPUB

+

First, how can we make HTML documents self-contained? This is an old problem with many potential solutions. WARC, webarchive, and MHTML are all file formats designed to contain all the resources needed to render a web page. But these formats are more designed for snapshotting an existing website, rather than serving as a single source of truth for a web document. From my research, the most sensible format for this purpose is EPUB.

+

EPUB is a “distribution and interchange format for digital publications and documents”, per the EPUB 3 Overview. Reductively, an EPUB is a ZIP archive of web files: HTML, CSS, JS, and assets like images and fonts. On a technical level, what distinguishes EPUB from archival formats is that EPUB includes well-specified files that describe metadata about a document. On a social level, EPUB appears to be the HTML publication format with the most adoption and momentum in 2024, compared to moribund formats like Mobi.

+

The EPUB spec has all the gory details, but to give you a rough sense, a sample EPUB might have the following file structure:

+
+
sample.epub
├── META-INF
│ └── container.xml
└── EPUB
├── package.opf
├── nav.xhtml
├── chapter1.xhtml
├── chapter2.xhtml
└── img
└── sample.jpg
+
+

An EPUB contains content documents (like chapter1.xhtml and chapter2.xhtml) which contain the core HTML content. Content documents can contain relative links to assets in the EPUB, like img/sample.jpg. The navigation document (nav.xhtml) provides a table of contents, and the package document (package.opf) provides metadata about the document. These files collectively define one “rendition” of the whole document, and the container file (container.xml) points to each rendition contained in the EPUB.

+

The EPUB format optimizes for machine-readable content and metadata. HTML content is required to be in XML format (hence, XHTML). Document metadata like the title and author is provided in structured form in the package document. The navigation document has a carefully prescribed tag structure so the TOC can be consistently extracted.

+

Overall, EPUB’s structured format makes it a solid candidate for a single-file HTML document container. However, EPUB is not a silver bullet. EPUB is quite permissive in what kinds of content can be put into a content document.

+

For example, a major issue for self-containment is that EPUB content can embed external assets. A content document can legally include an image or font file whose src is a URL to a hosted server. This is not hypothetical, either; as of the time of writing, Google Doc’s EPUB exporter will emit CSS that will @include external Google Fonts files. The problem is that such an EPUB will not render correctly without an internet connection, nor will it render correctly if Google changes the URLs of its font files.

+

Hence, I will propose a new format which I call a portable EPUB, which is an EPUB with additional requirements and recommendations to improve PDF-like portability. The first requirement is:

+
Local asset requirement: All assets (like images, scripts, and fonts) embedded in a content document of a portable EPUB must refer to local files included in the EPUB. Hyperlinks to external files are permissible.
+

Consistency vs. Flexibility in Rendering

+

There is a fundamental tension between consistency and flexibility in document rendering. A PDF is consistent because it is designed to render in one way: one layout, one choice of fonts, one choice of colors, one pagination, and so on. Consistency is desirable because an author can be confident that their document will look good for a reader (or at least, not look bad). Consistency has subtler benefits — because a PDF is chunked into a consistent set of pages, a passage can be cited by referring to the page containing the passage.

+

On the other hand, flexibility is desirable because people want to read documents under different conditions. Device conditions include screen size (from phone to monitor) and screen capabilities (E-ink vs. LCD). Some readers may prefer larger fonts or higher contrasts for visibility, alternative color schemes for color blindness, or alternative font faces for dyslexia. Sufficiently flexible documents can even permit readers to select a level of detail appropriate for their background (here’s an example).

+

Finding a balance between consistency and flexibility is arguably the most fundamental design challenge in attempting to replace PDF with EPUB. To navigate this trade-off, we first need to talk about EPUB reading systems, or the tools that render an EPUB for human consumption. To get a sense of variation between reading systems, I tried rendering this post as an EPUB (without any styling, just HTML) on four systems: Calibre, Adobe Digital Editions, Apple Books, and Amazon Kindle. This is how the first page looks on each system (omitting Calibre because it looked the same as Adobe Digital Editions):

+
+
+ +
Figure 6: Adobe Digital Editions
+
+
+ +
Figure 7: Apple Books
+
+
+ +
Figure 8: Amazon Kindle
+
+
+

Calibre and Adobe Digital Editions both render the document in a plain web view, as if you opened the HTML file directly in the browser. Apple Books applies some styling, using the New York font by default and changing link decorations. Amazon Kindle increases the line height and also uses my Kindle’s globally-configured default font, Bookerly.

+

As you can see, an EPUB may look quite different on different reading systems. The variation displayed above seems reasonable to me. But how different is too different? For instance, I was recently reading A History of Writing on my Kindle. Here’s an example of how a figure in the book renders on the Kindle:

+
+ +
Figure 9: A figure in the EPUB version of A History of Writing on my Kindle
+
+

When I read this page, I thought, “wow, this looks like crap.” The figure is way too small (although you can long-press the image and zoom), and the position of the figure seems nonsensical. I found a PDF version online, and indeed the PDF’s figure has a proper size in the right location:

+
+ +
Figure 10: A figure in the PDF version of A History of Writing on my Mac
+
+

This is not a fully fair comparison, but it nonetheless exemplifies an author’s reasonable concern today with EPUB: what if it makes my document looks like crap?

+

Principles for Consistent EPUB Rendering

+

I think the core solution for consistently rendering EPUBs comes down to this:

+
    +
  1. The document format (i.e., portable EPUB) needs to establish a subset of HTML (call it “portable HTML”) which could represent most, but not all, documents.
  2. +
  3. Reading systems need to guarantee that a document within the subset will always look reasonable under all reading conditions.
  4. +
  5. If a document uses features outside this subset, then the document author is responsible for ensuring the readability of the document.
  6. +
+

If someone wants to write a document such as this post, then that person need not be a frontend web developer to feel confident that their document will render reasonably. Conversely, if someone wants to stuff the entire Facebook interface into an EPUB, then fine, but it’s on them to ensure the document is responsive.

+

For instance, one simple version of portable HTML could be described by this grammar:

+
+
Document ::= <article> Block* </article>
Block ::= <p> Inline* </p> | <figure> Block* </figure>
Inline ::= text | <strong> Inline* </strong>
+
+

The EPUB spec already defines a comparable subset for navigation documents. I am essentially proposing to extend this idea for content documents, but as a soft constraint rather than a hard constraint. Finding the right subset of HTML will take some experimentation, so I can only gesture toward the broad solution here.

+
Portable HTML rendering requirement: if a document only uses features in the portable HTML subset, then a portable EPUB reading system must guarantee that the document will render reasonably.
+
Portable HTML generation principle: when possible, systems that generate portable EPUB should output portable HTML.
+

A related challenge is to define when a particular rendering is “good” or “reasonable”, so one could evaluate either a document or a reading system on its conformance to spec. For instance, if document content is accidentally rendered in an inaccesible location off-screen, then that would be a bad rendering. A more aggressive definition might say that any rendering which violates accessibility guidelines is a bad rendering. Again, finding the right standard for rendering quality will take some experimentation.

+

If an author is particularly concerned about providing a single “canonical” rendering of their document, one fallback option is to provide a fixed-layout rendition. The EPUB format permits a rendition to specify that it should be rendered in fixed viewport size and optionally a fixed pagination. A fixed-layout rendition could then manually position all content on the page, similar to a PDF. Of course, this loses the flexibility of a reflowable rendition. But an EPUB could in theory provide multiple renditions, offering users the choice of whichever best suits their reading conditions and aesthetic preferences.

+
Fixed-layout fallback principle: systems that generate portable EPUB can consider providing both a reflowable and fixed-layout rendition of a document.
+

It’s possible that the reading system, the document author, and the reader can each express preferences about how a document should render. If these preferences are conflicting, then the renderer should generally prioritize the reader over the author, and the author over the reading system. This is an ideal use case for the “cascading” aspect of CSS:

+
Cascading styles principle: both documents and reading systems should express stylistic preferences (such as font face, font size, and document width) as CSS styles which can be overriden (e.g., do not use !important). The reading system should load the CSS rules such that the priority order is reading system styles < document styles < reader styles.
+

A Lighter EPUB Reading System

+

The act of working with PDFs is relatively fluid. I can download a PDF, quickly open it in a PDF reading system like Preview, and keep or discard the PDF as needed. But EPUB reading systems feel comparatively clunky. Loading an EPUB into Apple Books or Calibre will import the EPUB into the application’s library, which both copies and potentially decompresses the file. Loading an EPUB on a Kindle requires waiting several minutes for the Send to Kindle service to complete.

+

Worse, EPUB reading systems often don’t give you appropriate control over rendering an EPUB. For example, to emulate the experience of reading a book, most reading systems will chunk an EPUB into pages. A reader cannot scroll the document but rather “turn” the page, meaning textually-adjacent content can be split up between pages. Whether a document is paginated or scrolled should be a reader’s choice, but 3/4 reading systems I tested would only permit pagination (Calibre being the exception).

+

Therefore I decided to build a lighter EPUB reading system, Bene. You’re using it right now. This document is an EPUB — you can download it by clicking the button in the top-right corner. The styling and icons are mostly borrowed from pdf.js. Bene is implemented in Tauri, so it can work as both a desktop app and a browser app. Please appreciate this picture of Bene running as a desktop app:

+
+ +
Figure 11: The Bene reading system running as a desktop app. Wow! It works!
+
+

Bene is designed to make opening and reading an EPUB feel fast and non-committal. The app is much quicker to open on my Macbook (<1sec) than other desktop apps. It decompresses files on-the-fly so no additional disk space is used. The backend is implemented in Rust and compiled to Wasm for the browser version.

+

The general design goal of Bene is to embody my ideals for a portable EPUB reader. That is, a utilitarian interface into an EPUB that satisfies my additional requirements for portability. Bene allows you to configure document rendering by changing the font size (try the +/- buttons in the top bar) and the viewer width (if you’re on desktop, move your mouse over the right edge of the document, and drag the handle). Long-term, I want Bene to also provide richer document interactions than a standard EPUB reader, which means we must discuss scripting.

+

Defensively Scripting EPUBs

+

To some people, the idea of code in their documents is unappealing. Last time one of my document-related projects was posted to Hacker News, the top comment was complaining about dynamic documents. The sentiment is understandable — concerns include:

+
    +
  • Bad code: your document shouldn’t crash or glitch due to a failure in a script.
  • +
  • Bad browsers: your document shouldn’t fail to render when a browser updates.
  • +
  • Bad actors: a malicious document shouldn’t be able to pwn your computer.
  • +
  • Bad interfaces: a script shouldn’t cause your document to become unreadable.
  • +
+

Yet, document scripting provides many opportunities for improving how we communicate information. For one example, if you haven’t yet, try hovering your mouse over any instance of the term portable EPUB (or long press it on a touch screen). You should see a tooltip appear with the term’s definition. The goal of these tooltips is to simplify reading a document that contains a lot of specialized notation or terminology. If you forget a definition, you can quickly look it up without having to jump around.

+

The key design challenge is how to permit useful scripting behaviors while limiting the downsides of scripting. One strategy is as follows:

+
Structure over scripts principle: documents should prefer structural annotations over scripts where possible. Documents should rely on reading systems to utilize structure where possible.
+

As an example of this principle, consider how the portable EPUB definition and references are expressed in this document:

+
+
+
<p><dfn-container>Hence, I will propose a new format which I call a <dfn id="portable-epub">portable EPUB</dfn>, which is an EPUB with additional requirements and recommendations to improve PDF-like portability.</dfn-container> The first requirement is:</p>
+
Listing 5: Creating a definition
+
+
+
For one example, if you haven't yet, try hovering your mouse over any instance of the term <a href="#portable-epub" data-target="dfn">portable EPUB</a> (or long press it on a touch screen).
+
Listing 6: Referencing a definition
+
+
+

The definition uses the <dfn> element wrapped in a custom <dfn-container> element to indicate the scope of the definition. The reference to the definition uses a standard anchor with an addition data-target attribute to emphasize that a definition is being linked. The document itself does not provide a script. The Bene reading system automatically detects these annotations and provides the tooltip interaction.

+

Encapsulating Scripts with Web Components

+

But what if a document wants to provide an interactive component that isn’t natively supported by the reading system? For instance, I have recently been working with The Rust Programming Language, a textbook that explains the different features of Rust. It contains a lot of passages like this one:

+
+
let x = 5;
let x = x + 1;
{
let x = x * 2;
println!("The value of x in the inner scope is: {x}");
}
println!("The value of x is: {x}");
}
+

This program first binds x to a value of 5. Then it creates a new variable x by repeating let x =, taking the original value and adding 1 so the value of x is then 6. Then, within an inner scope created with the curly brackets, the third let statement also shadows x and creates a new variable, multiplying the previous value by 2 to give x a value of 12. When that scope is over, the inner shadowing ends and x returns to being 6. When we run this program, it will output the following:

+
+

A challenge in reading this passage is finding the correspondences between the prose and the code. An interactive code reading component can help you track those correspondences, like this (try mousing-over or clicking-on each sentence):

+
+
fn main() { 
+  let x = 5
+  let x = x + 1
+  { 
+    let x = x * 2
+    println!(“The value of x in the inner scope is: {x}”);
+  }
+  println!(“The value of x is: {x}”);
+}
+

This program first binds x to a value of 5.Then it creates a new variable x by repeating let x =,taking the original value and adding 1 so the value of x is then 6.Then, within an inner scope created with the curly brackets,the third let statement also shadows x and creates a new variable,multiplying the previous value by 2 to give x a value of 12.When that scope is over, the inner shadowing ends and x returns to being 6.

+
+

The interactive code description component is used as follows:

+
+
<code-description>
<pre><code>fn main() {
let <span id="code-1">x</span> = <span id="code-2">5</span>;
<!-- rest of the code... -->
}</code></pre>
<p>
<code-step>This program first binds <a href="#code-1"><code>x</code></a> to a value of <a href="#code-2"><code>5</code></a>.</code-step>
<!-- rest of the prose... -->
</p>
</code-description>
+
+

Again, the document content contains no actual script. It contains a custom element <code-description>, and it contains a series of annotations as spans and anchors. The <code-description> element is implemented as a web component.

+

Web components are a programming model for writing encapsulated interactive fragments of HTML, CSS, and Javascript. Web components are one of many ways to write componentized HTML, such as React, Solid, Svelte, and Angular. I see web components as the most suitable as a framework for portable EPUBs because:

+
    +
  • Web components are a standardized technology. Its key features like custom elements (for specifying the behavior of novel elements) and shadow trees (for encapsulating a custom element from the rest of the document) are part of the official HTML and DOM specifications. This improves the likelihood that future browsers will maintain backwards compatibility with web components written today.
  • +
  • Web components are designed for tight encapusulation. The shadow tree mechanism ensures that styling applied within a custom component cannot accidentally affect other components on the page.
  • +
  • Web components have a decent ecosystem to leverage. As far as I can tell, web components are primarily used by Google, which has created notable frameworks like Lit.
  • +
  • Web components provide a clear fallback mechanism. If a renderer does not support Javascript, or if a renderer loses the ability to render web components, then an HTML renderer will simply ignore custom tags and render their contents.
  • +
+

Thus, I propose one principle and one requirement:

+
Encapsulated scripts principle: interactive components should be implemented as web components when possible, or otherwise be carefully designed to avoid conflicting with the base document or other components.
+
Components fallback requirement: interactive components must provide a fallback mechanism for rendering a reasonable substitute if Javascript is disabled.
+

Where To Go From Here?

+

Every time I have told someone “I want to replace PDF”, the statement has been met with extreme skepticism. Hopefully this document has convinced you that HTML-via-EPUB could potentially be a viable and desirable document format for the future.

+

My short-term goal is to implement a few more documents in the portable EPUB format, such as my PLDI paper. That will challenge both the file format and the reading system to be flexible enough to support each document type. In particular, each document should look good under a range of reading conditions (screen sizes, font sizes and faces, etc.).

+

My long-term goal is to design a document language that makes it easy to generate portable EPUBs. Writing XHTML by hand is not reasonable. I designed Nota before I was thinking about EPUBs, so its next iteration will be targeted at this new format.

+

If you have any thoughts about how to make this work or why I’m wrong, let me know by email or Twitter or Mastodon or wherever this gets posted. If you would like to help out, please reach out! This is just a passion project in my free time (for now…), so any programming or document authoring assistance could provide a lot of momentum to the project.

+

But What About…

+

A brief postscript for a few things I haven’t touched on.

+

…security? You might dislike the idea that document authors can run arbitrary Javascript on your personal computer. But then again, you presumably use both a PDF reader and a web browser on the daily, and those both run Javascript. What I’m proposing is not really any less secure than our current state of affairs. If anything, I’d hope that browsers are more battle-hardened than PDF viewers regarding code execution. Certainly the designers of EPUB reading systems should be careful to not give documents any additional capabilities beyond those already provided by the browser.

+

…privacy? Modern web sites use many kinds of telemetry and cookies to track user behavior. I strongly believe that EPUBs should not follow this trend. Telemetry must at least require the explicit consent of the user, and even that may be too generous. Companies will inevitably do things like offer discounts in exchange for requiring your consent to telemetry, similar to Amazon’s Kindle ads policy. Perhaps it is better to preempt this behavior by banning all tracking.

+

…aesthetics? People often intuit that LaTeX-generated PDFs look prettier than HTML documents, or even prettier than PDFs created by other software. This is because Donald Knuth took his job very seriously. In particular, the Knuth-Plass line-breaking algorithm tends to produce better-looking justified text than whatever algorithm is used by browsers.

+

There’s two ways to make progress here. One is for browsers to provide more typography tools. Allegedly, text-wrap: pretty is supposed to help, but in my brief testing it doesn’t seem to improve line-break quality. The other way is to pre-calculate line breaks, which would only work for fixed-layout renditions.

+

…page citations? I think we just have to give up on citing content by pages. Instead, we should mandate a consistent numbering scheme for block elements within a document, and have people cite using that scheme. (Allison Morrell points out this is already the standard in the Canadian legal system.) For example, Bene will auto-number all blocks. If you’re on a desktop, try hovering your mouse in the left column next to the top-right of any paragraph.

+

…annotations? Ideally it should be as easy to mark up an EPUB as a PDF. The Web Annotations specification seems to be a good starting point for annotating EPUBs. Web Annotations seem designed for annotations on “targetable” objects, like a labeled element or a range of text. It’s not yet clear how to deal with free-hand annotations, especially on reflowable documents.

+ + + \ No newline at end of file diff --git a/tests/ref/files/1d75da8a42d8f937/portable_epubs/pdf/portable_epubs.metadata.json b/crates/tests/ref/examples/portable_epubs/pdf/portable_epubs.metadata.json similarity index 100% rename from tests/ref/files/1d75da8a42d8f937/portable_epubs/pdf/portable_epubs.metadata.json rename to crates/tests/ref/examples/portable_epubs/pdf/portable_epubs.metadata.json diff --git a/crates/tests/ref/examples/relative_path_links/html/child.html b/crates/tests/ref/examples/relative_path_links/html/child.html new file mode 100644 index 0000000..3dfb31f --- /dev/null +++ b/crates/tests/ref/examples/relative_path_links/html/child.html @@ -0,0 +1,144 @@ + + + + + +

Child Document

+

This document is in a subdirectory.

+

Link to Parent Directory

+

Go back to the root document.

+

Link to Sibling (Explicit Same Dir)

+

See the sibling in the same directory.

+

Link to Sibling (Implicit Same Dir)

+

Also see the sibling again with implicit path.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/relative_path_links/html/root.html b/crates/tests/ref/examples/relative_path_links/html/root.html new file mode 100644 index 0000000..9ef6c90 --- /dev/null +++ b/crates/tests/ref/examples/relative_path_links/html/root.html @@ -0,0 +1,143 @@ + + + + + +

Root Document

+

This is the root of the test project.

+

Links to Subdirectory

+

See the child document in the subdir.

+

Also check out the sibling.

+

More Content

+

This tests that subdirectory paths transform correctly.

+ + + \ No newline at end of file diff --git a/crates/tests/ref/examples/relative_path_links/html/sibling.html b/crates/tests/ref/examples/relative_path_links/html/sibling.html new file mode 100644 index 0000000..89f16bd --- /dev/null +++ b/crates/tests/ref/examples/relative_path_links/html/sibling.html @@ -0,0 +1,144 @@ + + + + + +

Sibling Document

+

This is the sibling document in the subdirectory.

+

Link to Sibling

+

Go to the child document.

+

Link to Parent Directory

+

Return to root.

+

Content

+

Testing various relative path patterns.

+ + + \ No newline at end of file diff --git a/tests/ref/examples/relative_path_links/pdf/relative_path_links.metadata.json b/crates/tests/ref/examples/relative_path_links/pdf/relative_path_links.metadata.json similarity index 100% rename from tests/ref/examples/relative_path_links/pdf/relative_path_links.metadata.json rename to crates/tests/ref/examples/relative_path_links/pdf/relative_path_links.metadata.json diff --git a/crates/tests/ref/examples/severance_minusep_minus1/html/severance-ep-1.html b/crates/tests/ref/examples/severance_minusep_minus1/html/severance-ep-1.html new file mode 100644 index 0000000..cf90c07 --- /dev/null +++ b/crates/tests/ref/examples/severance_minusep_minus1/html/severance-ep-1.html @@ -0,0 +1,171 @@ + + + + Good news about hell - Severance [s1/e1] + + +

Good news about hell - Severance [s1/e1]

+ +

The first thing to notice is the colour palette. She is dressed in blue, but her hair is chestnut red. It spills out for the frame of her figure into the table around it, blockaded at its border by chairs and a carpet clad in green, yellow, then green again; then gray. The establishing shot is a bird’s eye view of an unknown woman who is soon revealed to have been put in the board room by someone else’s design, who learns about her predicament only by a man’s voice that emanates from the little device that rests on the table along with the woman, arranged so that it aims directly at her head.

+

This opening image is a graph of the subject’s predicament on the severed floor at Lumon. Blue is the company colour. Employees are almost invariably dressed in shades of it– navy, midnight, Prussian, Oxford, cobalt– and more reliably so as we work our way up the hierarchy. Red is unruly passion, the tone of tempers that itch to tear off the straitjacket directives, to disregulate the business-as-usual in which there is no obvious place for illicit activities. Green is the accent of Macro Data Refinement, the division of Lumon in which the show’s protagonists are employed. The device directs a man’s voice at a woman’s body in an attempt to keep her tempers in check, to ensure her firecraft does not smoke out the staid edifice of personality management, to order her “perceptual chronologies” accordingly. (Later in the episode, we learn that she almost manages to “break in” on the control room during that opening sequence: the solidity of its enclosure is threatened from the very first.)

+

It is instructive to attempt to articulate the dynamics that this graph indexes before we start talking about other scenes in the show. Graphs are not at one with what they represent, for in the decision to render ‘data’ in the very act of a representation, we both lose and gain distinction of the dynamics in question. The voice that opens Helly R up to the world of Lumon’s severed floor begins: “Who are you?” This question is a mistake. We retroactively learn, in a later scene, that Mark S was in fact supposed to begin with a less interrogative, more perfunctory: “Hi there, you on the table. I wonder if you’d mind taking a brief survey.” As Irving puts it: “You [Mark S] skipped the preamble”. Helly R is thrust, by this accident, immediately into questioning not only herself, but also the self-assurance of the voice that interrogates her. Does this voice in my head [she could be thinking] really know what it is doing? Or is it just a role of similarly confused actors struggling to stick to a badly written script?

+

This episode-length recap of the first episode names this graph ‘the Helly incident’, a poorly executed orientation of Helly’s newfound subjectivity that can be blamed at one level on Mark S (for starting with the wrong part of the manual), at another on Mr. Milchick (for misguiding Mark while he was distracted setting up the visual feed), on Ms. Cobel (for giving Mark Petey K’s old manual without redacting his obscurely scribbled notes and paper bookmarks), or even on Irving (for neglecting to intervene and clarify how Mark should begin being the more senior refiner in the situation: “Irving will be there to shadow. Just stick to the flowchart and escalate properly depending on dialectics.”). Wherever to place blame, there is doubtless a misconfiguration that takes place. Helly’s instinctual reaction seems to be to try to kill the voice pointed at her head, rather than to befriend it as Mark states he did (where Petey was Mark). (Helly will eventually have sex with the source of the voice, rather than murdering or fraternizing with it.) In this episode, however, Mark (the voice’s source) is physically assaulted by Helly, dented in his temple by the same vocalization device that mediated their first communication.

+ +

So this is the Macro Data refiner’s situation. On the one hand, she is affronted with a voice that compels her to abide by the rules and permits her to enjoy some small reliefs (egress from a locked room) if she concedes to it. On the other, she is always teeming and thus flirting with red, considering escape routes that involve drawing blood, setting off alarms, or removing clothes.

+

This unruly red is what Macro Data Refinement’s greening procedures are supposed to contain to produce a completely controlled and scripted blot of blue. Perhaps this is why the glipse of the vacant desks planned for the severed floor’s expansion are draped in purple, for that shade of subjectivity would better incorporate the contrasting contours into a unified and taskable tone. The red that threatens Lumon’s corporate, calm, and collected blue (the Lumon logo is a water droplet that suspiciously resembles a camera) is splattered across scenes in the episode. It is, for example, the envelope that Petey slips Mark at the company-owned restaurant Pip’s with the suggestion that he should read it if he wants to know “what’s going on down there”. It is the sweater Mark wears to his sister’s dinnerless dinner party, punctuated by red place mats (“what a lot of people overlook, I think, is that life is not food”), where the ontological substance of his innie is called into question, and where we learn about the passions he has lost– the history of World War II, educating, whiskey– the last of which seems to have given way to an indiscriminate consumption of beer, wine, anything that will drown out the clarity of sober consciousness. It is the general hue of his sister’s house, which consisently wants him to question that placid blue of his company-subsidized housing at Baird Creek Manor.

+

This dinner tells us something more about the subject in question in Severance. Just as Helly’s outie had alerted us to the basic principle in the video her innie was shown in curiously lo-fi resolution to conclude her innie’s orientation– “perceptual chronologies… surgically split”– Mark’s predicament is comparably explained to him by another more or less ignorant (we can’t help but imagine) third party: “One’s memories are bifurcated, so when you’re at work, you have no recollection of what it is you do there.” As pretentious as they are, the dinner’s guests do seem to be attuned to an important dimension of the meaning of life, which is that it can’t only be about satiating biological needs such as food. What each individual ‘needs’ is a disharmonious melange of needs and demands, openings of desire that emerge not only through a graph of bare necessities– food, water, shelter– but also through capricious carapaces that emerge from more ambiguous pinings in the social sphere– company, care, love. The real question of Lumon’s smooth functioning is whether it will be able to effectively plug up these pinings, the incidental moments at work where one wonders what one is really doing with one’s life, whether the company can really manage its employees’ unsanctioned thoughts and the way in which those illicit ideas seep into the daily practice of their workerhood. More on the plasticity of our needs and drives to satisfy them in later posts.

+ +

Ms. Cobel, in contrast to Helly’s and Mark’s doubtful and doubting red, is a stormy and icy blue. (We must wait until season two to uncover the historical and psychological depth of this colour for Harmony Cobel.) She is the figure with a body that seems to be the most in charge, of those we meet in this episode. Though Ms. Cobel is not a master in herself, it seems, for she too is subjected to a disembodied voice-via-device, ‘the board’, albeit which only appears evidently as an ear so far (“The board won’t be contributing to this meeting vocally”). Cobel is responsible for keeping the severed floor’s uncertainty in check, the ‘head’ that sits atop the variegated limbs of its disobedient body.

+

When Cobel reprimands Mark for his derailing of Helly’s orientation, she recalls an obscure and theological aspect of her parentage:

+
You know, my mother was an atheist. She used to say that there was good news and bad news about hell. The good news is, hell is just the product of a morbid human imagination. The bad news is, whatever humans can imagine, they can usually create.
+

At the close of the episode, just before Mark’s senile neighbor Mrs. Selvig (who we have only heard about through Mark’s voice thus far, when he is on the phone with her) visually reveals herself to be the same woman as Ms. Cobel, she gives a slightly different account of her heritage:

+
You know, my mother was a Catholic. She used to say it takes the saints eight hours to bless a sleeping child. I hope you aren’t rushing the saints.
+

It’s unclear at this point whether Cobel is a severed worker like Mark, or whether there is some other reason for her (strange, almost senseless) duplicity. Why lie about the religious leanings of one’s mother? Or maybe ‘mother’ is actually a name for something else, a kind of interim authority that gives synthetic weight to some hearsay, rumor, or idle phrase. (The other cameo of an ambiguously defined mother in this episode is in question five of Helly’s orientation survey: “To the best of your memory, what is or was the color of your mother’s eyes?”) Perhaps it is that, severed or not, atheist or Catholic, Cobel’s subjectivity is structured by a comparable split in her perceptual chronologies, whereby some memories (of her mother) get more airtime in her conscious experience of herself than others.

+

Severance flirts with this idea extensively, that the innie/outie dyad is analagous to the unconscious/conscious experience that we, as subjects, have of ourselves. Mark’s sister Devon hints at the psycho-logical reading of the severed condition in her diagnosis of Mark’s morose (outie) predicament as a state of failed therapy in response to mourning for his late wife: “I just feel like forgetting about her for eight hours a day isn’t the same thing as healing.” As with not-mothers and the plasticity of the drive, we will address the psychoanalytic implications here in later posts; but to finish I want to bring our attention to the imaging of time at work in just this first episode.

+

The fascinating details of failed synchronisation between all the watchfaces we see are enumerated in this Reddit thread. Many of the watch hands appear to be stalled, and the crossover from each to the next– as when Mark Scout switches his wrist watch in preparation for his elevator descent into the workday of innie Mark S– doesn’t match with our experience of the actors on screen. One of the few things we do know about the severance procedure is that it ‘alters perceptual chronologies’, and that this messing with a subject’s sense of time is thought to

+
    +
  1. make them more adequate or productive in a certain kind of work (for why else would Lumon go to the necessary lengths to sever some employees)
  2. +
  3. supposes to section off innie memories and experience from outie memories and experience
  4. +
+

So the subject’s subjectivity is marked by its sense of time, and Lumon’s success (profitability?) hinges in some way on altering their employees’ stable sense of it while in the space of the severed floor.

+

Mark S’s temporal predicament here has been explained by a man whose last name we get by speeding up the saying of his own, Karl Marx (Mar-k-S). Logically speaking, Marx argues, there is an amount of time that goes missing in the worker’s employment by way of a wage, when he advances some portion of his time to the capitalist in exchange for a pay-check one or more weeks later. I refer the reader interested in the details to chapter 20 of Capital Vol. I: but the essential point here is that it is through an obfuscation of the real value of a worker’s time that the capitalist manages to produce surplus-value. The production of this kind of time-distorted surplus-value is the engine of capitalism as a social relation that appears, on the surface, to be equally fair to capitalist and worker alike. So the project of controlling ‘perceptual chronologies’ with which Lumon seems to be so concerned is perhaps not as esoteric and inessential as it might at first seem. Perhaps it is an embodiment of the core ingredient of the company’s success as a company, of its incorporation as an entity that ought to be sustained even at the expense of its members’ happiness, their health, and their livelihoods.

+
+


+
+ +
+ + + \ No newline at end of file diff --git a/tests/ref/files/9a129f9c736a7947/severance-ep-1/pdf/severance-ep-1.metadata.json b/crates/tests/ref/examples/severance_minusep_minus1/pdf/severance-ep-1.metadata.json similarity index 100% rename from tests/ref/files/9a129f9c736a7947/severance-ep-1/pdf/severance-ep-1.metadata.json rename to crates/tests/ref/examples/severance_minusep_minus1/pdf/severance-ep-1.metadata.json diff --git a/tests/ref/examples/target_function/epub/target_function.metadata.json b/crates/tests/ref/examples/target_function/epub/target_function.metadata.json similarity index 67% rename from tests/ref/examples/target_function/epub/target_function.metadata.json rename to crates/tests/ref/examples/target_function/epub/target_function.metadata.json index 53384a7..4bdc0c6 100644 --- a/tests/ref/examples/target_function/epub/target_function.metadata.json +++ b/crates/tests/ref/examples/target_function/epub/target_function.metadata.json @@ -1,7 +1,7 @@ { "filetype": "epub", - "file_size": 2906, - "title": "main.xhtml", + "file_size": 2911, + "title": "Target Function", "language": "en", "spine_files": [ "main.xhtml" diff --git a/tests/ref/examples/target_function/epub/xhtml/main.xhtml b/crates/tests/ref/examples/target_function/epub/xhtml/main.xhtml similarity index 100% rename from tests/ref/examples/target_function/epub/xhtml/main.xhtml rename to crates/tests/ref/examples/target_function/epub/xhtml/main.xhtml diff --git a/crates/tests/ref/examples/target_function/html/main.html b/crates/tests/ref/examples/target_function/html/main.html new file mode 100644 index 0000000..6fdbdf5 --- /dev/null +++ b/crates/tests/ref/examples/target_function/html/main.html @@ -0,0 +1,141 @@ + + + + + +

Target Function Test

+

This test verifies that the target() function returns format-specific values.

+

Current format: html

+

Conditional Content

+

HTML-specific content: This appears only in HTML output

+ + + \ No newline at end of file diff --git a/tests/ref/examples/target_function/pdf/main.metadata.json b/crates/tests/ref/examples/target_function/pdf/main.metadata.json similarity index 100% rename from tests/ref/examples/target_function/pdf/main.metadata.json rename to crates/tests/ref/examples/target_function/pdf/main.metadata.json diff --git a/tests/ref/examples/target_function_in_module/epub/target_function_in_module.metadata.json b/crates/tests/ref/examples/target_function_in_module/epub/target_function_in_module.metadata.json similarity index 100% rename from tests/ref/examples/target_function_in_module/epub/target_function_in_module.metadata.json rename to crates/tests/ref/examples/target_function_in_module/epub/target_function_in_module.metadata.json diff --git a/tests/ref/examples/target_function_in_module/epub/xhtml/main.xhtml b/crates/tests/ref/examples/target_function_in_module/epub/xhtml/main.xhtml similarity index 100% rename from tests/ref/examples/target_function_in_module/epub/xhtml/main.xhtml rename to crates/tests/ref/examples/target_function_in_module/epub/xhtml/main.xhtml diff --git a/crates/tests/ref/examples/target_function_in_module/html/format_helper.html b/crates/tests/ref/examples/target_function_in_module/html/format_helper.html new file mode 100644 index 0000000..8843ed2 --- /dev/null +++ b/crates/tests/ref/examples/target_function_in_module/html/format_helper.html @@ -0,0 +1,135 @@ + + + + + + + \ No newline at end of file diff --git a/crates/tests/ref/examples/target_function_in_module/html/main.html b/crates/tests/ref/examples/target_function_in_module/html/main.html new file mode 100644 index 0000000..f6391ae --- /dev/null +++ b/crates/tests/ref/examples/target_function_in_module/html/main.html @@ -0,0 +1,143 @@ + + + + + +

Target Function in Module

+

Main File

+

Main: html

+

Imported Module

+

Module returns: html

+

Module Conditional

+

Module: HTML

+ + + \ No newline at end of file diff --git a/tests/ref/examples/target_function_in_module/pdf/format_helper.metadata.json b/crates/tests/ref/examples/target_function_in_module/pdf/format_helper.metadata.json similarity index 100% rename from tests/ref/examples/target_function_in_module/pdf/format_helper.metadata.json rename to crates/tests/ref/examples/target_function_in_module/pdf/format_helper.metadata.json diff --git a/tests/ref/examples/target_function_in_module/pdf/main.metadata.json b/crates/tests/ref/examples/target_function_in_module/pdf/main.metadata.json similarity index 100% rename from tests/ref/examples/target_function_in_module/pdf/main.metadata.json rename to crates/tests/ref/examples/target_function_in_module/pdf/main.metadata.json diff --git a/tests/ref/examples/target_function_in_package/epub/target_function_in_package.metadata.json b/crates/tests/ref/examples/target_function_in_package/epub/target_function_in_package.metadata.json similarity index 100% rename from tests/ref/examples/target_function_in_package/epub/target_function_in_package.metadata.json rename to crates/tests/ref/examples/target_function_in_package/epub/target_function_in_package.metadata.json diff --git a/tests/ref/examples/target_function_in_package/epub/xhtml/main.xhtml b/crates/tests/ref/examples/target_function_in_package/epub/xhtml/main.xhtml similarity index 100% rename from tests/ref/examples/target_function_in_package/epub/xhtml/main.xhtml rename to crates/tests/ref/examples/target_function_in_package/epub/xhtml/main.xhtml diff --git a/crates/tests/ref/examples/target_function_in_package/html/main.html b/crates/tests/ref/examples/target_function_in_package/html/main.html new file mode 100644 index 0000000..0483e5c --- /dev/null +++ b/crates/tests/ref/examples/target_function_in_package/html/main.html @@ -0,0 +1,141 @@ + + + + + +

Target Function in Package

+

Using bullseye package

+

Package sees: html

+

Using target()

+

Main file target: html

+ + + \ No newline at end of file diff --git a/tests/ref/files/1d75da8a42d8f937/portable_epubs/html/portable_epubs.html b/crates/tests/ref/files/1d75da8a42d8f937/portable_epubs/html/portable_epubs.html similarity index 100% rename from tests/ref/files/1d75da8a42d8f937/portable_epubs/html/portable_epubs.html rename to crates/tests/ref/files/1d75da8a42d8f937/portable_epubs/html/portable_epubs.html diff --git a/crates/tests/ref/files/1d75da8a42d8f937/portable_epubs/pdf/portable_epubs.metadata.json b/crates/tests/ref/files/1d75da8a42d8f937/portable_epubs/pdf/portable_epubs.metadata.json new file mode 100644 index 0000000..50e46c0 --- /dev/null +++ b/crates/tests/ref/files/1d75da8a42d8f937/portable_epubs/pdf/portable_epubs.metadata.json @@ -0,0 +1,5 @@ +{ + "filetype": "pdf", + "file_size": 5063041, + "page_count": 10 +} \ No newline at end of file diff --git a/crates/tests/ref/files/47c9eeba6b52a15f/cover-letter/pdf/cover-letter.metadata.json b/crates/tests/ref/files/47c9eeba6b52a15f/cover-letter/pdf/cover-letter.metadata.json new file mode 100644 index 0000000..4f7061e --- /dev/null +++ b/crates/tests/ref/files/47c9eeba6b52a15f/cover-letter/pdf/cover-letter.metadata.json @@ -0,0 +1,5 @@ +{ + "filetype": "pdf", + "file_size": 1212760, + "page_count": 1 +} \ No newline at end of file diff --git a/tests/ref/files/5b4554f7aaa1292c/test/epub/test.metadata.json b/crates/tests/ref/files/5b4554f7aaa1292c/test/epub/test.metadata.json similarity index 100% rename from tests/ref/files/5b4554f7aaa1292c/test/epub/test.metadata.json rename to crates/tests/ref/files/5b4554f7aaa1292c/test/epub/test.metadata.json diff --git a/tests/ref/files/6fdadcdcac7454ad/index/html/index.html b/crates/tests/ref/files/6fdadcdcac7454ad/index/html/index.html similarity index 100% rename from tests/ref/files/6fdadcdcac7454ad/index/html/index.html rename to crates/tests/ref/files/6fdadcdcac7454ad/index/html/index.html diff --git a/crates/tests/ref/files/6fdadcdcac7454ad/index/pdf/index.metadata.json b/crates/tests/ref/files/6fdadcdcac7454ad/index/pdf/index.metadata.json new file mode 100644 index 0000000..23d3525 --- /dev/null +++ b/crates/tests/ref/files/6fdadcdcac7454ad/index/pdf/index.metadata.json @@ -0,0 +1,5 @@ +{ + "filetype": "pdf", + "file_size": 4215, + "page_count": 1 +} \ No newline at end of file diff --git a/tests/ref/files/9a129f9c736a7947/severance-ep-1/html/severance-ep-1.html b/crates/tests/ref/files/9a129f9c736a7947/severance-ep-1/html/severance-ep-1.html similarity index 100% rename from tests/ref/files/9a129f9c736a7947/severance-ep-1/html/severance-ep-1.html rename to crates/tests/ref/files/9a129f9c736a7947/severance-ep-1/html/severance-ep-1.html diff --git a/crates/tests/ref/files/9a129f9c736a7947/severance-ep-1/pdf/severance-ep-1.metadata.json b/crates/tests/ref/files/9a129f9c736a7947/severance-ep-1/pdf/severance-ep-1.metadata.json new file mode 100644 index 0000000..f0089a1 --- /dev/null +++ b/crates/tests/ref/files/9a129f9c736a7947/severance-ep-1/pdf/severance-ep-1.metadata.json @@ -0,0 +1,5 @@ +{ + "filetype": "pdf", + "file_size": 898313, + "page_count": 2 +} \ No newline at end of file diff --git a/tests/ref/files/f0b104671a707ab2/multiple_links_inline/html/multiple_links_inline.html b/crates/tests/ref/files/f0b104671a707ab2/multiple_links_inline/html/multiple_links_inline.html similarity index 100% rename from tests/ref/files/f0b104671a707ab2/multiple_links_inline/html/multiple_links_inline.html rename to crates/tests/ref/files/f0b104671a707ab2/multiple_links_inline/html/multiple_links_inline.html diff --git a/crates/tests/ref/files/f0b104671a707ab2/multiple_links_inline/pdf/multiple_links_inline.metadata.json b/crates/tests/ref/files/f0b104671a707ab2/multiple_links_inline/pdf/multiple_links_inline.metadata.json new file mode 100644 index 0000000..b3203d9 --- /dev/null +++ b/crates/tests/ref/files/f0b104671a707ab2/multiple_links_inline/pdf/multiple_links_inline.metadata.json @@ -0,0 +1,5 @@ +{ + "filetype": "pdf", + "file_size": 3918, + "page_count": 1 +} \ No newline at end of file diff --git a/tests/helpers/comparison.rs b/crates/tests/src/helpers/comparison.rs similarity index 96% rename from tests/helpers/comparison.rs rename to crates/tests/src/helpers/comparison.rs index 50eb17e..37deea9 100644 --- a/tests/helpers/comparison.rs +++ b/crates/tests/src/helpers/comparison.rs @@ -82,7 +82,7 @@ fn get_reference_dir(actual_dir: &Path, test_name: &str, output_type: &str) -> P .unwrap_or(file_path.as_os_str()) .to_string_lossy(); - return PathBuf::from("tests/ref/files") + return PathBuf::from("ref/files") .join(&hash) .join(filename.as_ref()) .join(output_type); @@ -91,11 +91,11 @@ fn get_reference_dir(actual_dir: &Path, test_name: &str, output_type: &str) -> P // Default: project-based references let ref_base = if actual_dir.starts_with("examples/") { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") } else if actual_dir.starts_with("tests/cases/") { - PathBuf::from("tests/ref/cases") + PathBuf::from("ref/cases") } else { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") }; ref_base.join(test_name).join(output_type) } @@ -454,7 +454,7 @@ pub fn extract_css_metadata(css_path: &Path) -> Result Result { - use rheo::formats::epub::package::Package; + use rheo_epub::package::Package; use std::io::Read; use zip::ZipArchive; @@ -920,39 +920,3 @@ pub fn verify_epub_output(test_name: &str, actual_dir: &Path) { .expect("EPUB XHTML content mismatch"); }); } - -#[allow(unused)] -fn verify_included_files_present(output_dir: &Path, patterns: &[&str]) -> Result<(), String> { - let mut found = vec![false; patterns.len()]; - - for_each_file_with_ext(output_dir, "", |entry| { - if let Ok(rel_path) = entry.path().strip_prefix(output_dir) { - let path_str = rel_path.to_string_lossy(); - for (i, pattern) in patterns.iter().enumerate() { - if path_str.contains(*pattern) { - found[i] = true; - } - } - } - }); - - let missing: Vec<_> = patterns - .iter() - .enumerate() - .filter(|(i, _)| !found[*i]) - .map(|(_, p)| *p) - .collect(); - - if missing.is_empty() { - Ok(()) - } else { - Err(format!( - "Expected files not found in output:\n{}", - missing - .iter() - .map(|p| format!(" - {}", p)) - .collect::>() - .join("\n") - )) - } -} diff --git a/tests/helpers/fixtures.rs b/crates/tests/src/helpers/fixtures.rs similarity index 54% rename from tests/helpers/fixtures.rs rename to crates/tests/src/helpers/fixtures.rs index 5969e84..03bdf91 100644 --- a/tests/helpers/fixtures.rs +++ b/crates/tests/src/helpers/fixtures.rs @@ -1,4 +1,3 @@ -use rheo::OutputFormat; use std::fs; use std::path::{Path, PathBuf}; @@ -15,11 +14,10 @@ pub enum TestCase { project_path: PathBuf, }, /// Test a single .typ file - #[allow(dead_code)] SingleFile { name: String, file_path: PathBuf, - formats: Vec, + formats: Vec, metadata: Option, }, } @@ -29,9 +27,12 @@ impl TestCase { // Check if the path is a .typ file if is_single_file_test(raw_path) { let file_path = Path::new(raw_path); + // Use just the file stem (filename without extension) for the test name let name = file_path - .to_string_lossy() - .replace('/', "_slash") + .file_stem() + .unwrap() + .to_str() + .unwrap() .replace('.', "_full_stop") .replace(':', "_colon") .replace('-', "_minus"); @@ -40,18 +41,8 @@ impl TestCase { let metadata = read_test_metadata(file_path); let formats = metadata .as_ref() - .map(|m| { - m.formats - .iter() - .filter_map(|f| match f.as_str() { - "html" => Some(OutputFormat::Html), - "pdf" => Some(OutputFormat::Pdf), - "epub" => Some(OutputFormat::Epub), - _ => None, - }) - .collect() - }) - .unwrap_or_else(OutputFormat::all_variants); + .map(|m| m.formats.clone()) + .unwrap_or_else(|| vec!["html".to_string(), "epub".to_string(), "pdf".to_string()]); return Self::SingleFile { name, @@ -69,18 +60,8 @@ impl TestCase { let test_metadata = read_test_metadata(path); let formats = test_metadata .as_ref() - .map(|m| { - m.formats - .iter() - .filter_map(|f| match f.as_str() { - "html" => Some(OutputFormat::Html), - "pdf" => Some(OutputFormat::Pdf), - "epub" => Some(OutputFormat::Epub), - _ => None, - }) - .collect() - }) - .unwrap_or_else(OutputFormat::all_variants); + .map(|m| m.formats.clone()) + .unwrap_or_else(|| vec!["html".to_string(), "epub".to_string(), "pdf".to_string()]); Self::SingleFile { name, @@ -94,7 +75,7 @@ impl TestCase { project_path: path.into(), } } else { - panic!("test case should only be a file or a directory") + panic!("test case should only be a file or a directory"); } } @@ -112,31 +93,12 @@ impl TestCase { } } - /// Returns the file path for SingleFile tests, or None for Directory tests - #[allow(unused)] - pub fn file_path(&self) -> Option<&PathBuf> { + /// Returns the format names to test for this test case + pub fn formats(&self) -> Vec { match self { - TestCase::Directory { .. } => None, - TestCase::SingleFile { file_path, .. } => Some(file_path), - } - } - - /// Returns the project root directory for the test case - #[allow(unused)] - pub fn project_root(&self) -> PathBuf { - match self { - TestCase::Directory { project_path, .. } => project_path.clone(), - TestCase::SingleFile { file_path, .. } => { - // For single files, use parent directory as project root - file_path.parent().unwrap_or(Path::new(".")).to_path_buf() + TestCase::Directory { .. } => { + vec!["html".to_string(), "epub".to_string(), "pdf".to_string()] } - } - } - - /// Returns the formats to test for this test case - pub fn formats(&self) -> Vec { - match self { - TestCase::Directory { .. } => OutputFormat::all_variants(), TestCase::SingleFile { formats, .. } => formats.clone(), } } @@ -154,19 +116,3 @@ impl TestCase { } } } - -/// Set up test environment (e.g., create temp directories) -#[allow(dead_code)] -pub fn setup_test_environment() -> PathBuf { - let test_store = PathBuf::from("tests/store"); - std::fs::create_dir_all(&test_store).expect("Failed to create tests/store"); - test_store -} - -/// Clean up test environment after tests complete -#[allow(dead_code)] -pub fn cleanup_test_environment(path: &PathBuf) { - if path.exists() { - std::fs::remove_dir_all(path).ok(); - } -} diff --git a/tests/helpers/markers.rs b/crates/tests/src/helpers/markers.rs similarity index 94% rename from tests/helpers/markers.rs rename to crates/tests/src/helpers/markers.rs index b4bd850..57bd647 100644 --- a/tests/helpers/markers.rs +++ b/crates/tests/src/helpers/markers.rs @@ -200,8 +200,9 @@ mod tests { #[test] fn test_read_test_metadata_from_file() { // Test reading markers from an actual example file - let path = Path::new("examples/blog_site/content/index.typ"); - let metadata = read_test_metadata(path).unwrap(); + let manifest_dir = option_env!("CARGO_MANIFEST_DIR").unwrap_or("."); + let path = Path::new(manifest_dir).join("../../examples/blog_site/content/index.typ"); + let metadata = read_test_metadata(&path).unwrap(); assert_eq!(metadata.formats, vec!["html", "pdf"]); assert_eq!( metadata.description, @@ -212,8 +213,9 @@ mod tests { #[test] fn test_read_test_metadata_pdf_only() { // Test reading PDF-only markers - let path = Path::new("examples/cover-letter.typ"); - let metadata = read_test_metadata(path).unwrap(); + let manifest_dir = option_env!("CARGO_MANIFEST_DIR").unwrap_or("."); + let path = Path::new(manifest_dir).join("../../examples/cover-letter.typ"); + let metadata = read_test_metadata(&path).unwrap(); assert_eq!(metadata.formats, vec!["pdf"]); assert_eq!( metadata.description, diff --git a/tests/helpers/mod.rs b/crates/tests/src/helpers/mod.rs similarity index 100% rename from tests/helpers/mod.rs rename to crates/tests/src/helpers/mod.rs diff --git a/tests/helpers/reference.rs b/crates/tests/src/helpers/reference.rs similarity index 93% rename from tests/helpers/reference.rs rename to crates/tests/src/helpers/reference.rs index 009ef18..8782c4f 100644 --- a/tests/helpers/reference.rs +++ b/crates/tests/src/helpers/reference.rs @@ -38,7 +38,7 @@ pub fn update_html_references( .unwrap_or(file_path.as_os_str()) .to_string_lossy(); - PathBuf::from("tests/ref/files") + PathBuf::from("ref/files") .join(&hash) .join(filename.as_ref()) .join("html") @@ -71,11 +71,11 @@ pub fn update_html_references( /// Get project-based reference directory fn get_project_ref_dir(project_path: &Path, test_name: &str, output_type: &str) -> PathBuf { let ref_base = if project_path.starts_with("examples/") { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") } else if project_path.starts_with("tests/cases/") { - PathBuf::from("tests/ref/cases") + PathBuf::from("ref/cases") } else { - PathBuf::from("tests/ref/examples") // fallback + PathBuf::from("ref/examples") // fallback }; ref_base.join(test_name).join(output_type) } @@ -101,29 +101,29 @@ pub fn update_pdf_references(test_name: &str, actual_dir: &Path) -> Result<(), S .unwrap_or(file_path.as_os_str()) .to_string_lossy(); - PathBuf::from("tests/ref/files") + PathBuf::from("ref/files") .join(&hash) .join(filename.as_ref()) .join("pdf") } else { // Fallback to project-based path let ref_base = if actual_dir.starts_with("examples/") { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") } else if actual_dir.starts_with("tests/cases/") { - PathBuf::from("tests/ref/cases") + PathBuf::from("ref/cases") } else { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") }; ref_base.join(test_name).join("pdf") } } else { // Project-based test let ref_base = if actual_dir.starts_with("examples/") { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") } else if actual_dir.starts_with("tests/cases/") { - PathBuf::from("tests/ref/cases") + PathBuf::from("ref/cases") } else { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") }; ref_base.join(test_name).join("pdf") }; @@ -196,29 +196,29 @@ pub fn update_epub_references(test_name: &str, actual_dir: &Path) -> Result<(), .unwrap_or(file_path.as_os_str()) .to_string_lossy(); - PathBuf::from("tests/ref/files") + PathBuf::from("ref/files") .join(&hash) .join(filename.as_ref()) .join("epub") } else { // Fallback to project-based path let ref_base = if actual_dir.starts_with("examples/") { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") } else if actual_dir.starts_with("tests/cases/") { - PathBuf::from("tests/ref/cases") + PathBuf::from("ref/cases") } else { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") }; ref_base.join(test_name).join("epub") } } else { // Project-based test let ref_base = if actual_dir.starts_with("examples/") { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") } else if actual_dir.starts_with("tests/cases/") { - PathBuf::from("tests/ref/cases") + PathBuf::from("ref/cases") } else { - PathBuf::from("tests/ref/examples") + PathBuf::from("ref/examples") }; ref_base.join(test_name).join("epub") }; diff --git a/tests/helpers/test_store.rs b/crates/tests/src/helpers/test_store.rs similarity index 100% rename from tests/helpers/test_store.rs rename to crates/tests/src/helpers/test_store.rs diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs new file mode 100644 index 0000000..2990d77 --- /dev/null +++ b/crates/tests/src/lib.rs @@ -0,0 +1,4 @@ +// Test-only library for rheo integration tests + +// Re-export test helpers at the crate root for easy access +pub mod helpers; diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/adobe-digital-edition.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/adobe-digital-edition.jpg new file mode 100644 index 0000000..3965474 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/adobe-digital-edition.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/after-resize.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/after-resize.jpg new file mode 100644 index 0000000..ef30c8b Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/after-resize.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/apple-books.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/apple-books.jpg new file mode 100644 index 0000000..2d38574 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/apple-books.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/before-resize.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/before-resize.jpg new file mode 100644 index 0000000..5ee7da3 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/before-resize.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/bene.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/bene.png new file mode 100644 index 0000000..4b7c9ed Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/bene.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/history-of-writing-kindle.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/history-of-writing-kindle.jpg new file mode 100644 index 0000000..fbf2458 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/history-of-writing-kindle.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/history-of-writing-pdf.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/history-of-writing-pdf.jpg new file mode 100644 index 0000000..cbb24be Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/history-of-writing-pdf.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/kindle.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/kindle.jpg new file mode 100644 index 0000000..e01c1f0 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/kindle.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/tags.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/tags.jpg new file mode 100644 index 0000000..7640a41 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/img/tags.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/portable_epubs.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/portable_epubs.typ new file mode 100644 index 0000000..9f2ec39 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_post_slashportable_epubs_full_stoptyp/portable_epubs.typ @@ -0,0 +1,476 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Long-form article with custom HTML elements and code examples + +#let html-element(body, name: "div", attrs: (:)) = context { + if target() == "html" or target() == "epub" { + html.elem(name, attrs: attrs, body) + } else { + block(body) + [#attrs] + } +} + +#let custom-element(name, attrs: (:)) = html-element.with(name: name).with(attrs: attrs) + +#let header = custom-element("header") +#let authors = custom-element("doc-authors") +#let author = custom-element("doc-author") +#let author-name = custom-element("doc-author-name") +#let author-affiliation = custom-element("doc-author-affiliation") +#let publication-date = custom-element("doc-publication-date") +#let abstract = custom-element("doc-abstract") +#let section = custom-element("section") +#let definition = custom-element("dfn-container") + +#let defined-word(id: "", body) = custom-element("dfn")(attrs: (id: id), body) + +#let callout(body) = custom-element("div")(attrs: (class: "callout"), body) +#let def-link(id, body) = custom-element("a")(attrs: (href: "#" + id, data-target: "dfn"), body) +#let code-description = custom-element("code-description") +#let code-step = custom-element("code-step") +#let pre = custom-element("pre") +#let span = custom-element("span") +#let code-def(id, body) = span(attrs: (id: id), body) +#let code-description(body) = { + let verbatimize(items, indent) = { + items + .filter(child => child.func() == enum.item) + .map(item => { + if item.body.has("children") { + let children = item.body.children + let item-idx = children.position(child => child.func() == enum.item) + if item-idx != none { + let prefix = children.slice(0, item-idx) + (span(indent), prefix.join(), "\n", verbatimize(children.slice(item-idx), indent + " ")).join() + } else { + (span(indent), children.join()).join() + } + } else { + (span(indent), item.body).join() + } + }) + .join("\n") + } + pre(verbatimize(body.children, "")) +} +#let code-steps(body) = { + body + .children + .map(child => { + if child.has("body") { + code-step(child.body) + } else { + child + } + }) + .join() +} + +#set document(title: "Portable EPUBs") + +#header[ + #title() + #authors[ + #author[ + #author-name[Will Crichton] + #author-affiliation[Brown University] + ] + ] + #publication-date[January 25, 2024] + #abstract[ + Despite decades of advances in document rendering technology, most of the world's documents are stuck in the 1990s due to the limitations of PDF. + Yet, modern document formats like HTML have yet to provide a competitive alternative to PDF. This post explores what prevents HTML documents from being portable, and I propose a way forward based on the EPUB format. To demonstrate my ideas, this post is presented using a prototype EPUB reading system. + ] +] + += The Good and Bad of PDF + +PDF is the de facto file format for reading and sharing digital documents like papers, textbooks, and flyers. People use the PDF format for several reasons: + +- *PDFs are self-contained.* A PDF is a single file that contains all the images, fonts, and other data needed to render it. It's easy to pass around a PDF. A PDF is unlikely to be missing some critical dependency on your computer. + +- *PDFs are rendered consistently.* A PDF specifies precisely how it should be rendered, so a PDF author can be confident that a reader will see the same document under any conditions. + +- *PDFs are stable over time.* PDFs from decades ago still render the same today. PDFs have a #link("https://www.iso.org/standard/75839.html")[relatively stable standard]. PDFs cannot be easily edited. + +Yet, in the 32 years since the initial release of PDF, a lot has changed. People print out documents less and less. People use phones, tablets, and e-readers to read digital documents. The internet happened; web browsers now provide a platform for rendering rich documents. These changes have laid bare the limitations of PDF: + +- *PDFs cannot easily adapt to different screen sizes.* Most PDFs are designed to mimic 8.5x11" paper (or worse, #link("https://en.wikipedia.org/wiki/PDF#/media/File:Seitengroesse_PDF_7.png")[145,161 km#super[2]]). These PDFs are readable on a computer monitor, but they are less readable on a tablet, and far less readable on a phone. + +- *PDFs cannot be easily understood by programs.* A plain PDF is just a scattered sequence of lines and characters. For accessibility, screen readers #link("https://dl.acm.org/doi/10.1145/2851581.2892588")[may not know] which order to read through the text. For data extraction, scraping tables out of a PDF is an #link("https://openaccess.thecvf.com/content/CVPR2022/html/Smock_PubTables-1M_Towards_Comprehensive_Table_Extraction_From_Unstructured_Documents_CVPR_2022_paper.html")[open] #link("https://ieeexplore.ieee.org/document/5277546")[area] of #link("https://www.sciencedirect.com/science/article/pii/S030645731830205X?casa_token=jNV6uhUNLs0AAAAA:p6EMBh3X54Ulv9Ghtca1WPR2iL6fkhpVOVsbXj7zzinRYVa72HUGQb6VBOIPFdFoHwjEGDSB")[research]. + +- *PDFs cannot easily express interaction.* PDFs were primarily designed as static documents that cannot react to user input beyond filling in forms. + +These pros and cons can be traced back to one key fact: the PDF representation of a document is fundamentally unstructured. A PDF consists of commands like: + +#figure[ + ``` + Move the cursor to the right by 0.5 inches. + Set the current font color to black. + Draw the text "Hello World" at the current position. + ``` +] + +PDF commands are unstructured because a document's organization is only clear to a person looking at the rendered document, and not clear from the commands themselves. Reflowing, accessibility, data extraction, and interaction _all_ rely on programmatically understanding the structure of a document. Hence, these aspects are not easy to integrate with PDFs. + +This raises the question: *how can we design digital documents with the benefits of PDFs but without the limitations?* + += Can't We Just Fix PDF? + +A simple answer is to improve the PDF format. After all, we already have billions of PDFs — why reinvent the wheel? + +The designers of PDF are well aware of its limitations. I carefully hedged each bullet with "easily", because PDF does make it _possible_ to overcome each limitation, at least partially. PDFs can be annotated with their #link("https://opensource.adobe.com/dc-acrobat-sdk-docs/library/pdfmark/pdfmark_Logical.html")[logical structure] to create a #link("https://www.washington.edu/accesstech/documents/tagged-pdf/")[tagged PDF]. Most PDF exporters will not add tags automatically — the simplest option is to use Adobe's subscription-only #link("https://www.adobe.com/acrobat/acrobat-pro.html")[Acrobat Pro], which provides an "Automatically tag PDF" action. For example, here is #link("https://arxiv.org/abs/2310.04368")[a recent paper of mine] with added tags: + +#figure( + image("img/tags.jpg"), + caption: [A LaTeX-generated paper with automatically added tags.], +) + +If you squint, you can see that the logical structure closely resembles the HTML document model. The document has sections, headings, paragraphs, and links. Adobe characterizes the logical structure as an accessibility feature, but it has other benefits. You may be surprised to know that Adobe Acrobat allows you to reflow tagged PDFs at different screen sizes. You may be unsurprised to know that reflowing does not always work well. For example: + +#figure[ + #figure( + image("img/before-resize.jpg"), + caption: [A section of the paper in its default fixed layout. Note that the second paragraph is wrapped around the code snippet.], + ) + + #figure( + image("img/after-resize.jpg"), + caption: [The same section of the paper after reflowing to a smaller width. Note that the code is now interleaved with the second paragraph.], + ) +] + +In theory, these issues could be fixed. If the world's PDF exporters could be modified to include logical structure. If Adobe's reflowing algorithm could be improved to fix its edge cases. If the reflowing algorithm could be specified, and if Adobe were willing to release it publicly, and if it were implemented in each PDF viewer. And that doesn't even cover interaction! So in practice, I don't think we can just fix the PDF format, at least within a reasonable time frame. + += The Good and Bad of HTML + +In the meantime, we already have a structured document format which can be flexibly and interactively rendered: HTML (and CSS and Javascript, but here just collectively referred to as HTML). The HTML format provides almost exactly the inverse advantages and disadvantages of PDF. + +- *HTML can more easily adapt to different screen sizes.* Over the last 20 years, web developers and browser vendors have created a wide array of techniques for #link("https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design")[responsive design]. +- *HTML can be more easily understood by a program.* HTML provides both an inherent structure plus #link("https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA")[additional attributes] to support accessibility tools. +- *HTML can more easily express interaction.* People have used HTML to produce amazing interactive documents that would be impossible in PDF. Think: #link("https://distill.pub/")[Distill.pub], #link("https://explorabl.es/")[Explorable Explanations], #link("https://ciechanow.ski/")[Bartosz Ciechanowski], and #link("http://worrydream.com/")[Bret Victor], just to name a few. + +Again, these advantages are hedged with "more easily". One can easily produce a convoluted or inaccessible HTML document. But on balance, these aspects are more true than not compared to PDF. However, HTML is lacking where PDF shines: + +- *HTML is not self-contained.* HTML files may contain URL references to external files that may be hosted on a server. One can rarely download an HTML file and have it render correctly without an internet connection. +- *HTML is not always rendered consistently.* HTML's dynamic layout means that an author may not see the same document as a reader. Moreover, HTML layout is not fully specified, so browsers may differ in their implementation. +- *HTML is not fully stable over time.* Browsers try to maintain backwards compatibility (#link("https://www.spacejam.com/1996/")[come on and slam!]), but the HTML format is still evolving. The #link("https://html.spec.whatwg.org/")[HTML standard] is a "living standard" due to the rapidly changing needs and feature sets of modern browsers. + +So I've been thinking: *how can we design HTML documents to gain the benefits of PDFs without losing the key strengths of HTML?* The rest of this document will present some early prototypes and tentative proposals in this direction. + += Self-Contained HTML with EPUB + +First, how can we make HTML documents self-contained? This is an old problem with many potential solutions. #link("https://en.wikipedia.org/wiki/WARC_(file_format)")[WARC], #link("https://en.wikipedia.org/wiki/Webarchive")[webarchive], and #link("https://en.wikipedia.org/wiki/MHTML")[MHTML] are all file formats designed to contain all the resources needed to render a web page. But these formats are more designed for snapshotting an existing website, rather than serving as a single source of truth for a web document. From my research, the most sensible format for this purpose is EPUB. + +EPUB is a "distribution and interchange format for digital publications and documents", per the #link("https://www.w3.org/TR/epub-overview-33/#")[EPUB 3 Overview]. Reductively, an EPUB is a ZIP archive of web files: HTML, CSS, JS, and assets like images and fonts. On a technical level, what distinguishes EPUB from archival formats is that EPUB includes well-specified files that describe metadata about a document. On a social level, EPUB appears to be the HTML publication format with the most adoption and momentum in 2024, compared to moribund formats like #link("https://en.wikipedia.org/wiki/Mobipocket")[Mobi]. + +The #link("https://www.w3.org/TR/epub-33")[EPUB spec] has all the gory details, but to give you a rough sense, a sample EPUB might have the following file structure: + +#figure[ + ``` + sample.epub + ├── META-INF + │ └── container.xml + └── EPUB + ├── package.opf + ├── nav.xhtml + ├── chapter1.xhtml + ├── chapter2.xhtml + └── img + └── sample.jpg + ``` +] + +An EPUB contains #link("https://www.w3.org/TR/epub-33/#sec-contentdocs")[content documents] (like `chapter1.xhtml` and `chapter2.xhtml`) which contain the core HTML content. Content documents can contain relative links to assets in the EPUB, like `img/sample.jpg`. The #link("https://www.w3.org/TR/epub-33/#sec-nav")[navigation document] (`nav.xhtml`) provides a table of contents, and the #link("https://www.w3.org/TR/epub-33/#sec-package-doc")[package document] (`package.opf`) provides metadata about the document. These files collectively define one "rendition" of the whole document, and the #link("https://www.w3.org/TR/epub-33/#sec-container-metainf-container.xml")[container file] (`container.xml`) points to each rendition contained in the EPUB. + +The EPUB format optimizes for machine-readable content and metadata. HTML content is required to be in XML format (hence, #strong[X]HTML). Document metadata like the title and author is provided in structured form in the package document. The navigation document has a carefully prescribed tag structure so the TOC can be consistently extracted. + +Overall, EPUB's structured format makes it a solid candidate for a single-file HTML document container. However, EPUB is not a silver bullet. EPUB is quite permissive in what kinds of content can be put into a content document. + +For example, a major issue for self-containment is that EPUB content can embed external assets. A content document can legally include an image or font file whose `src` is a URL to a hosted server. This is not hypothetical, either; as of the time of writing, Google Doc's EPUB exporter will emit CSS that will `@include` external Google Fonts files. The problem is that such an EPUB will not render correctly without an internet connection, nor will it render correctly if Google changes the URLs of its font files. + +#par[#definition[ + Hence, I will propose a new format which I call a #defined-word(id: "portable-epub")[*portable EPUB*], which is an EPUB with additional requirements and recommendations to improve PDF-like portability. The first requirement is: +]] + +#callout[ + *Local asset requirement:* All assets (like images, scripts, and fonts) embedded in a content document of a portable EPUB must refer to local files included in the EPUB. Hyperlinks to external files are permissible. +] + += Consistency vs. Flexibility in Rendering + +There is a fundamental tension between consistency and flexibility in document rendering. A PDF is consistent because it is designed to render in one way: one layout, one choice of fonts, one choice of colors, one pagination, and so on. Consistency is desirable because an author can be confident that their document will look good for a reader (or at least, not look bad). Consistency has subtler benefits --- because a PDF is chunked into a consistent set of pages, a passage can be cited by referring to the page containing the passage. + +On the other hand, flexibility is desirable because people want to read documents under different conditions. Device conditions include screen size (from phone to monitor) and screen capabilities (E-ink vs. LCD). Some readers may prefer larger fonts or higher contrasts for visibility, alternative color schemes for color blindness, or alternative font faces for #link("https://opendyslexic.org/")[dyslexia]. Sufficiently flexible documents can even permit readers to select a level of detail appropriate for their background (#link("https://tomasp.net/coeffects/")[here's an example]). + +Finding a balance between consistency and flexibility is arguably the most fundamental design challenge in attempting to replace PDF with EPUB. To navigate this trade-off, we first need to talk about #defined-word(id: "reading-system")[EPUB reading systems], or the tools that render an EPUB for human consumption. To get a sense of variation between reading systems, I tried rendering this post as an EPUB (without any styling, just HTML) on four systems: #link("https://calibre-ebook.com/")[Calibre], #link("https://www.adobe.com/solutions/ebook/digital-editions.html")[Adobe Digital Editions], #link("https://www.apple.com/apple-books/")[Apple Books], and #link("https://www.amazon.com/dp/B09SWW583J")[Amazon Kindle]. This is how the first page looks on each system (omitting Calibre because it looked the same as Adobe Digital Editions): + +#figure[ + #figure( + image("img/adobe-digital-edition.jpg"), + caption: [Adobe Digital Editions], + ) + + #figure( + image("img/apple-books.jpg"), + caption: [Apple Books], + ) + + #figure( + image("img/kindle.jpg"), + caption: [Amazon Kindle], + ) +] + +Calibre and Adobe Digital Editions both render the document in a plain web view, as if you opened the HTML file directly in the browser. Apple Books applies some styling, using the #link("https://en.wikipedia.org/wiki/New_York_(2019_typeface)")[New York] font by default and changing link decorations. Amazon Kindle increases the line height and also uses my Kindle's globally-configured default font, #link("https://en.wikipedia.org/wiki/Bookerly")[Bookerly]. + +As you can see, an EPUB may look quite different on different reading systems. The variation displayed above seems reasonable to me. But how different is _too_ different? For instance, I was recently reading #link("https://press.uchicago.edu/ucp/books/book/distributed/H/bo70558916.html")[_A History of Writing_] on my Kindle. Here's an example of how a figure in the book renders on the Kindle: + +#figure( + image("img/history-of-writing-kindle.jpg"), + caption: [A figure in the EPUB version of _A History of Writing_ on my Kindle], +) + +When I read this page, I thought, "wow, this looks like crap." The figure is way too small (although you can long-press the image and zoom), and the position of the figure seems nonsensical. I found a PDF version online, and indeed the PDF's figure has a proper size in the right location: + +#figure( + image("img/history-of-writing-pdf.jpg"), + caption: [A figure in the PDF version of _A History of Writing_ on my Mac], +) + +This is not a fully fair comparison, but it nonetheless exemplifies an author's reasonable concern today with EPUB: _what if it makes my document looks like crap?_ + += Principles for Consistent EPUB Rendering + +I think the core solution for consistently rendering EPUBs comes down to this: + ++ The document format (i.e., #def-link("portable-epub")[portable EPUB]) needs to establish a subset of HTML (call it "portable HTML") which could represent most, but not all, documents. ++ Reading systems need to guarantee that a document within the subset will always look reasonable under all reading conditions. ++ If a document uses features outside this subset, then the document author is responsible for ensuring the readability of the document. + +If someone wants to write a document such as this post, then that person need not be a frontend web developer to feel confident that their document will render reasonably. Conversely, if someone wants to stuff the entire Facebook interface into an EPUB, then fine, but it's on them to ensure the document is responsive. + +For instance, one simple version of portable HTML could be described by this grammar: + +#figure[ + ``` + Document ::=
Block*
+ Block ::=

Inline*

|
Block*
+ Inline ::= text | Inline* + ``` +] + +The EPUB spec already defines a comparable subset for #link("https://www.w3.org/TR/epub-33/#sec-nav-def-model")[navigation documents]. +I am essentially proposing to extend this idea for content documents, but as a soft constraint rather than a hard constraint. Finding the right subset of HTML will take some experimentation, so I can only gesture toward the broad solution here. + +#callout[ + *Portable HTML rendering requirement:* if a document only uses features in the portable HTML subset, then a #def-link("portable-epub")[portable EPUB] reading system must guarantee that the document will render reasonably. +] + +#callout[ + *Portable HTML generation principle:* when possible, systems that generate #def-link("portable-epub")[portable EPUB] should output portable HTML. +] + +A related challenge is to define when a particular rendering is "good" or "reasonable", so one could evaluate either a document or a reading system on its conformance to spec. For instance, if document content is accidentally rendered in an inaccesible location off-screen, then that would be a bad rendering. A more aggressive definition might say that any rendering which violates accessibility guidelines is a bad rendering. Again, finding the right standard for rendering quality will take some experimentation. + +If an author is particularly concerned about providing a single "canonical" rendering of their document, one fallback option is to provide a #link("https://www.w3.org/TR/epub-33/#sec-fixed-layouts")[fixed-layout rendition]. The EPUB format permits a rendition to specify that it should be rendered in fixed viewport size and optionally a fixed pagination. A fixed-layout rendition could then manually position all content on the page, similar to a PDF. Of course, this loses the flexibility of a reflowable rendition. But an EPUB could in theory provide #link("https://www.w3.org/TR/epub-multi-rend-11/")[multiple renditions], offering users the choice of whichever best suits their reading conditions and aesthetic preferences. + +#callout[ + *Fixed-layout fallback principle:* systems that generate #def-link("portable-epub")[portable EPUB] can consider providing both a reflowable and fixed-layout rendition of a document. +] + +It's possible that the reading system, the document author, and the reader can each express preferences about how a document should render. If these preferences are conflicting, then the renderer should generally prioritize the reader over the author, and the author over the reading system. This is an ideal use case for the "cascading" aspect of CSS: + +#callout[ + *Cascading styles principle:* both documents and reading systems should express stylistic preferences (such as font face, font size, and document width) as CSS styles which can be overriden (e.g., do not use `!important`). The reading system should load the CSS rules such that the priority order is reading system styles < document styles < reader styles. +] + += A Lighter EPUB Reading System + +The act of working with PDFs is relatively fluid. I can download a PDF, quickly open it in a PDF reading system like #link("https://en.wikipedia.org/wiki/Preview_(macOS)")[Preview], and keep or discard the PDF as needed. But EPUB reading systems feel comparatively clunky. Loading an EPUB into Apple Books or Calibre will import the EPUB into the application's library, which both copies and potentially decompresses the file. Loading an EPUB on a Kindle requires waiting several minutes for the #link("https://www.amazon.com/sendtokindle")[Send to Kindle] service to complete. + +Worse, EPUB reading systems often don't give you appropriate control over rendering an EPUB. For example, to emulate the experience of reading a book, most reading systems will chunk an EPUB into pages. A reader cannot scroll the document but rather "turn" the page, meaning textually-adjacent content can be split up between pages. Whether a document is paginated or scrolled should be a reader's choice, but 3/4 reading systems I tested would only permit pagination (Calibre being the exception). + +Therefore I decided to build a lighter EPUB reading system, #link("https://github.com/nota-lang/bene/")[Bene]. You're using it right now. This document is an EPUB — you can download it by clicking the button in the top-right corner. The styling and icons are mostly borrowed from #link("https://github.com/mozilla/pdf.js")[pdf.js]. Bene is implemented in #link("https://tauri.app/")[Tauri], so it can work as both a desktop app and a browser app. Please appreciate this picture of Bene running as a desktop app: + +#figure( + image("img/bene.png"), + caption: [The Bene reading system running as a desktop app. Wow! It works!], +) + +Bene is designed to make opening and reading an EPUB feel fast and non-committal. The app is much quicker to open on my Macbook (\<1sec) than other desktop apps. It decompresses files on-the-fly so no additional disk space is used. The backend is implemented in Rust and compiled to Wasm for the browser version. + +The general design goal of Bene is to embody my ideals for a #def-link("portable-epub")[portable EPUB] reader. That is, a utilitarian interface into an EPUB that satisfies my additional requirements for portability. Bene allows you to configure document rendering by changing the font size (try the +/- buttons in the top bar) and the viewer width (if you're on desktop, move your mouse over the right edge of the document, and drag the handle). Long-term, I want Bene to also provide richer document interactions than a standard EPUB reader, which means we must discuss scripting. + += Defensively Scripting EPUBs + +To some people, the idea of code in their documents is unappealing. Last time one of my #link("https://nota-lang.org/")[document-related projects] was posted to Hacker News, the #link("https://news.ycombinator.com/item?id=37951616")[top comment] was complaining about dynamic documents. The sentiment is understandable — concerns include: + +- *Bad code:* your document shouldn't crash or glitch due to a failure in a script. +- *Bad browsers:* your document shouldn't fail to render when a browser updates. +- *Bad actors:* a malicious document shouldn't be able to pwn your computer. +- *Bad interfaces:* a script shouldn't cause your document to become unreadable. + +Yet, document scripting provides many opportunities for improving how we communicate information. For one example, if you haven't yet, try hovering your mouse over any instance of the term portable EPUB (or long press it on a touch screen). You should see a tooltip appear with the term's definition. The goal of these tooltips is to simplify reading a document that contains a lot of specialized notation or terminology. If you forget a definition, you can quickly look it up without having to jump around. + +The key design challenge is how to permit useful scripting behaviors while limiting the downsides of scripting. One strategy is as follows: + +#callout[ + *Structure over scripts principle:* documents should prefer structural annotations over scripts where possible. Documents should rely on reading systems to utilize structure where possible. +] + +As an example of this principle, consider how the portable EPUB definition and references are expressed in this document: + +#figure[ + #figure( + ```html +

Hence, I will propose a new format which I call a portable EPUB, which is an EPUB with additional requirements and recommendations to improve PDF-like portability. The first requirement is:

+ ```, + caption: [Creating a definition], + ) + + #figure( + ```html + For one example, if you haven't yet, try hovering your mouse over any instance of the term portable EPUB (or long press it on a touch screen). + ```, + caption: [Referencing a definition], + ) +] + +The definition uses the #link("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dfn")[``] element wrapped in a custom `` element to indicate the scope of the definition. The reference to the definition uses a standard anchor with an addition `data-target` attribute to emphasize that a definition is being linked. The document itself does not provide a script. The Bene reading system automatically detects these annotations and provides the tooltip interaction. + += Encapsulating Scripts with Web Components + +But what if a document wants to provide an interactive component that isn't natively supported by the reading system? For instance, I have recently been working with *The Rust Programming Language*, a textbook that explains the different features of Rust. It contains a lot of passages #link("https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing")[like this one:] + +#figure[ + ```rust + let x = 5; + let x = x + 1; + { + let x = x * 2; + println!("The value of x in the inner scope is: {x}"); + } + println!("The value of x is: {x}"); + } + ``` + + This program first binds `x` to a value of `5`. Then it creates a new variable `x` by repeating `let x =`, taking the original value and adding `1` so the value of `x` is then `6`. Then, within an inner scope created with the curly brackets, the third `let` statement also shadows `x` and creates a new variable, multiplying the previous value by `2` to give `x` a value of `12`. When that scope is over, the inner shadowing ends and `x` returns to being `6`. When we run this program, it will output the following: +] + +A challenge in reading this passage is finding the correspondences between the prose and the code. An interactive code reading component can help you track those correspondences, like this (try mousing-over or clicking-on each sentence): + +//
+// +//
fn main() {
+//     let x = 5;
+//     let x = x + 1;
+//     {
+//         let x = x * 2;
+//         println!("The value of x in the inner scope is: {x}");
+//     }
+//     println!("The value of x is: {x}");
+// }
+//

+// This program first binds x to a value of 5. +// Then it creates a new variable x by repeating let x =, +// taking the original value and adding 1 +// so the value of x is then 6. +// Then, within an inner scope created with the curly brackets, +// the third let statement also shadows x and creates +// a new variable, +// multiplying the previous value by 2 +// to give x a value of 12. +// When that scope is over, the inner shadowing ends and x returns to being 6. +//

+//
+//
+ +#figure[ + #code-description[ + + fn main() { + + let #code-def("code-1")[x] = #code-def("code-2")[5]; + + #code-def("code-4")[let #code-def("code-3")[x] =] #code-def("code-15")[x] #code-def("code-16")[+] #code-def("code-5")[1]; + + #code-def("code-7")[{] + + #code-def("code-8")[let] #code-def("code-9")[x] = #code-def("code-10")[x] #code-def("code-17")[\*] #code-def("code-11")[2]; + + println!("The value of x in the inner scope is: {x}"); + + #code-def("code-13")[}] + + println!("The value of x is: {#code-def("code-14")[x]}"); + + } + ] + + #par[ + #code-steps[ + + This program first binds #link("#code-1")[`x`] to a value of #link("#code-2")[`5`]. + + Then it creates a new variable #link("#code-3")[`x`] by repeating #link("#code-4")[`let x =`], + + taking #link("#code-15")[the original value] and #link("#code-16")[adding] #link("#code-5")[1] so the value of #link("#code-3")[`x`] is then 6. + + Then, within an #link("#code-7")[inner scope] created with the #link("#code-7")[curly] #link("#code-13")[brackets], + + the third #link("#code-8")[`let`] statement also shadows #link("#code-3")[`x`] and creates #link("#code-9")[a new variable], + + #link("#code-17")[multiplying] #link("#code-10")[the previous value] by #link("#code-11")[2] to give #link("#code-9")[`x`] a value of 12. + + When #link("#code-7")[that scope] #link("#code-13")[is over], #link("#code-9")[the inner shadowing] ends and #link("#code-14")[`x`] returns to being 6. + ] + ] +] + +The interactive code description component is used as follows: + +#figure[ + ```html + +
fn main() {
+      let x = 5;
+      
+  }
+

+ This program first binds x to a value of 5. + +

+
+ ``` +] + +Again, the document content contains no actual script. It contains a custom element ``, and it contains a series of annotations as spans and anchors. The `` element is implemented as a #link("https://developer.mozilla.org/en-US/docs/Web/API/Web_components")[web component]. + +Web components are a programming model for writing encapsulated interactive fragments of HTML, CSS, and Javascript. Web components are one of many ways to write componentized HTML, such as #link("https://react.dev/")[React], #link("https://www.solidjs.com/")[Solid], #link("https://svelte.dev/")[Svelte], and #link("https://angular.io/")[Angular]. I see web components as the most suitable as a framework for portable EPUBs because: + +- *Web components are a standardized technology.* Its key features like #link("https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements")[custom elements] (for specifying the behavior of novel elements) and #link("https://dom.spec.whatwg.org/#shadow-trees")[shadow trees] (for encapsulating a custom element from the rest of the document) are part of the official HTML and DOM specifications. This improves the likelihood that future browsers will maintain backwards compatibility with web components written today. +- *Web components are designed for tight encapusulation.* The shadow tree mechanism ensures that styling applied within a custom component cannot accidentally affect other components on the page. +- *Web components have a decent ecosystem to leverage.* As far as I can tell, web components are primarily used by Google, which has created notable frameworks like #link("https://lit.dev")[Lit]. +- *Web components provide a clear fallback mechanism.* If a renderer does not support Javascript, or if a renderer loses the ability to render web components, then an HTML renderer will simply ignore custom tags and render their contents. + +Thus, I propose one principle and one requirement: + +#callout[ + *Encapsulated scripts principle:* interactive components should be implemented as web components when possible, or otherwise be carefully designed to avoid conflicting with the base document or other components. +] + +#callout[ + *Components fallback requirement:* interactive components must provide a fallback mechanism for rendering a reasonable substitute if Javascript is disabled. +] + += Where To Go From Here? + +Every time I have told someone "I want to replace PDF", the statement has been met with extreme skepticism. Hopefully this document has convinced you that HTML-via-EPUB could potentially be a viable and desirable document format for the future. + +My short-term goal is to implement a few more documents in the #def-link("portable-epub")[portable EPUB] format, such as my #link("https://willcrichton.net/nota")[PLDI paper]. That will challenge both the file format and the reading system to be flexible enough to support each document type. In particular, each document should look good under a range of reading conditions (screen sizes, font sizes and faces, etc.). + +My long-term goal is to design a document language that makes it easy to generate #def-link("portable-epub")[portable EPUBs]. Writing XHTML by hand is not reasonable. I designed #link("https://nota-lang.org/")[Nota] before I was thinking about EPUBs, so its next iteration will be targeted at this new format. + +If you have any thoughts about how to make this work or why I'm wrong, let me know by #link("mailto:crichton.will@gmail.com")[email] or #link("https://twitter.com/tonofcrates")[Twitter] or #link("https://mastodon.social/@tonofcrates")[Mastodon] or wherever this gets posted. If you would like to help out, please reach out! This is just a passion project in my free time (for now...), so any programming or document authoring assistance could provide a lot of momentum to the project. + += But What About... + +A brief postscript for a few things I haven't touched on. + +*...security?* You might dislike the idea that document authors can run arbitrary Javascript on your personal computer. But then again, you presumably use both a PDF reader and a web browser on the daily, and those both run Javascript. What I'm proposing is not really any _less_ secure than our current state of affairs. If anything, I'd hope that browsers are more battle-hardened than PDF viewers regarding code execution. Certainly the designers of EPUB reading systems should be careful to not give documents any _additional_ capabilities beyond those already provided by the browser. + +*...privacy?* Modern web sites use many kinds of telemetry and cookies to track user behavior. I strongly believe that EPUBs should not follow this trend. Telemetry must _at least_ require the explicit consent of the user, and even that may be too generous. Companies will inevitably do things like offer discounts in exchange for requiring your consent to telemetry, similar to Amazon's #link("https://www.amazon.com/gp/help/customer/display.html?nodeId=GFNWCZJAM3SBQQZD")[Kindle ads policy]. Perhaps it is better to preempt this behavior by banning all tracking. + +*...aesthetics?* People often intuit that LaTeX-generated PDFs look prettier than HTML documents, or even prettier than PDFs created by other software. This is because Donald Knuth took his job #link("https://www-cs-faculty.stanford.edu/~knuth/dt.html")[very seriously]. In particular, the #link("https://onlinelibrary.wiley.com/doi/abs/10.1002/spe.4380111102?")[Knuth-Plass line-breaking algorithm] tends to produce better-looking justified text than whatever algorithm is used by browsers. + +There's two ways to make progress here. One is for browsers to provide more typography tools. Allegedly, `text-wrap: pretty` is #link("https://developer.chrome.com/blog/css-text-wrap-pretty/")[supposed to help], but in my brief testing it doesn't seem to improve line-break quality. The other way is to #link("https://mpetroff.net/2020/05/pre-calculated-line-breaks-for-html-css/")[pre-calculate line breaks], which would only work for fixed-layout renditions. + +*...page citations?* I think we just have to give up on citing content by pages. Instead, we should mandate a consistent numbering scheme for block elements within a document, and have people cite using that scheme. (Allison Morrell #link("https://twitter.com/AllisonDMorrell/status/1750728545905823856")[points out] this is already the standard in the Canadian legal system.) For example, Bene will auto-number all blocks. If you're on a desktop, try hovering your mouse in the left column next to the top-right of any paragraph. + +*...annotations?* Ideally it should be as easy to mark up an EPUB as a PDF. The #link("https://www.w3.org/TR/annotation-model/#selectors")[Web Annotations specification] seems to be a good starting point for annotating EPUBs. Web Annotations seem designed for annotations on "targetable" objects, like a labeled element or a range of text. It's not yet clear how to deal with free-hand annotations, especially on reflowable documents. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot1.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot1.jpg new file mode 100644 index 0000000..15ec7c6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot1.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot2.png new file mode 100644 index 0000000..86ef4bd Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot3.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot3.jpg new file mode 100644 index 0000000..5748da6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot3.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-apple-II.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-apple-II.jpg new file mode 100644 index 0000000..34b4c3b Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-apple-II.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-bell-works.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-bell-works.png new file mode 100644 index 0000000..21af54e Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-bell-works.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-commodore-pet.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-commodore-pet.jpg new file mode 100644 index 0000000..a3a14cd Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-commodore-pet.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-ibm-pc.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-ibm-pc.jpg new file mode 100644 index 0000000..5fdd501 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-ibm-pc.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-lumon-logo.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-lumon-logo.png new file mode 100644 index 0000000..ef615ce Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-lumon-logo.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot1.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot1.png new file mode 100644 index 0000000..00d9360 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot1.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot2.png new file mode 100644 index 0000000..b0084f6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot1.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot1.png new file mode 100644 index 0000000..dfa281e Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot1.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot2.png new file mode 100644 index 0000000..ba39e37 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-tamingtempers.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-tamingtempers.png new file mode 100644 index 0000000..6eaac35 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-tamingtempers.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/typst-links-citations-demo.mp4 b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/typst-links-citations-demo.mp4 new file mode 100644 index 0000000..a29ea41 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/typst-links-citations-demo.mp4 differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/index.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/index.typ new file mode 100644 index 0000000..e80f8be --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/index.typ @@ -0,0 +1,37 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Main blog index page with post listings + +#let div(_class: "", ..body) = html.elem("div", attrs: (class: _class), ..body) +#let br() = html.elem("br") +#let hr() = html.elem("hr") +#let ul(_class: "", ..body) = html.elem("ul", attrs: (class: _class), ..body) +#let li(_class: "", ..body) = html.elem("li", attrs: (class: _class), ..body) + +#let template(doc) = { + doc + context if target() == "html" or target() == "epub" { + div[ + #br() + #hr() + #ul[ + #li[#link("./index.typ")[Home]] + #li[#link("https://lachlankermode.com")[Learn more] about me] + #li[#link("https://ohrg.org")[Read other musings]] + ] + ] + } +} + +#show: template + += Screening the subject + +_Screening the subject_ is a blog that analyses content on both the big and small screen in reasonable detail, i.e. episode-by-episode or scene-by-scene. +Contact us at #link("mailto:info@ohrg.org")[info\@ohrg.org] for enquiries. + +// Be alerted of new content by subscribing to the #link("https://screening-the-subject.ohrg.org/feed.xml")[RSS feed]. + +- #link("./severance-ep-1.typ")[Severance, s1/e1] +- #link("./severance-ep-2.typ")[Severance, s1/e2] +- #link("./severance-ep-3.typ")[Severance, s1/e3] diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/references.bib b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/references.bib new file mode 100644 index 0000000..f8543cd --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/references.bib @@ -0,0 +1,30 @@ +@article{freudTotemTabooResemblances1919, + title = {Totem and {{Taboo}}: {{Resemblances Between}} the {{Psychic Lives}} of {{Savages}} and {{Neurotics}}}, + author = {Freud, Sigmund}, + translator = {Brill, A.A}, + year = {1919}, + journal = {Moffat, Yard and Company}, + volume = {50}, + number = {1}, + pages = {94--95}, + publisher = {LWW}, + urldate = {2025-06-05} +} + +@misc{lacanFamilyComplexesFormation2002, + title = {Family {{Complexes}} in the {{Formation}} of the {{Individual}}}, + author = {Lacan, Jacques}, + year = {2002}, + publisher = {Antony Rowe London}, + urldate = {2025-05-22}, + file = {/home/lox/Zotero/storage/HAKMXWZ5/Lacan - 2002 - Family Complexes in the Formation of the Individual.pdf} +} + +@article{mcgowanDistributionEnjoyment2021, + title = {The {{Distribution}} of {{Enjoyment}}}, + author = {McGowan, Todd}, + year = {2021}, + journal = {European Journal of Psychoanalysis}, + volume = {8}, + number = {1} +} diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-1.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-1.typ new file mode 100644 index 0000000..cd00d88 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-1.typ @@ -0,0 +1,111 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post with images, footnotes, and bibliography + +#import "index.typ": template +#show: template + +#set document(title: [Good news about hell - #emph[Severance] [s1/e1]]) + +#title() + +#image("img/severance-s1e1-shot1.jpg") + +The first thing to notice is the colour palette. +She is dressed in blue, but her hair is chestnut red. +It spills out for the frame of her figure into the table around it, blockaded at its border by chairs and a carpet clad in green, yellow, then green again; then gray. +The establishing shot is a bird's eye view of an unknown woman who is soon revealed to have been put in the board room by someone else's design, who learns about her predicament only by a man's voice that emanates from the little device that rests on the table along with the woman, arranged so that it aims directly at her head. + +This opening image is a graph of the subject's predicament on the severed floor at Lumon. +Blue is the company colour. +Employees are almost invariably dressed in shades of it-- navy, midnight, Prussian, Oxford, cobalt-- and more reliably so as we work our way up the hierarchy. +Red is unruly passion, the tone of tempers that itch to tear off the straitjacket directives, to disregulate the business-as-usual in which there is no obvious place for illicit activities. +Green is the accent of Macro Data Refinement, the division of Lumon in which the show's protagonists are employed. +The device directs a man's voice at a woman's body in an attempt to keep her tempers in check, to ensure her firecraft does not smoke out the staid edifice of personality management, to order her #quote[perceptual chronologies] accordingly. +(Later in the episode, we learn that she almost manages to #quote[break in] on the control room during that opening sequence: the solidity of its enclosure is threatened from the very first.) + +It is instructive to attempt to articulate the dynamics that this graph indexes before we start talking about other scenes in the show. +Graphs are not at one with what they represent, for in the decision to render 'data' in the very act of a representation, we both lose and gain distinction of the dynamics in question. +The voice that opens Helly R up to the world of Lumon's severed floor begins: #quote[Who are you?] +This question is a mistake. +We retroactively learn, in a later scene, that Mark S was in fact supposed to begin with a less interrogative, more perfunctory: #quote[Hi there, you on the table. I wonder if you'd mind taking a brief survey.] +As Irving puts it: #quote[You [Mark S] skipped the preamble]. +Helly R is thrust, by this accident, immediately into questioning not only herself, but also the self-assurance of the voice that interrogates her. +Does this voice in my head [she could be thinking] really know what it is doing? +Or is it just a role of similarly confused actors struggling to stick to a badly written script? + +#link("https://www.youtube.com/watch?v=QIsLXuVeUgM")[This episode-length recap] of the first episode names this graph 'the Helly incident', a poorly executed orientation of Helly's newfound subjectivity that can be blamed at one level on Mark S (for starting with the wrong part of the manual), at another on Mr. Milchick (for misguiding Mark while he was distracted setting up the visual feed), on Ms. Cobel (for giving Mark Petey K's old manual without redacting his obscurely scribbled notes and paper bookmarks), or even on Irving (for neglecting to intervene and clarify how Mark should begin being the more senior refiner in the situation: #quote[Irving will be there to shadow. Just stick to the flowchart and escalate properly depending on dialectics.]). +Wherever to place blame, there is doubtless a misconfiguration that takes place. +Helly's instinctual reaction seems to be to try to kill the voice pointed at her head, rather than to befriend it as Mark states he did (where Petey was Mark). +(Helly will eventually have sex with the source of the voice, rather than murdering or fraternizing with it.) +In this episode, however, Mark (the voice's source) is physically assaulted by Helly, dented in his temple by the same vocalization device that mediated their first communication. + +#image("img/severance-s1e1-shot2.png") + +So this is the Macro Data refiner's situation. +On the one hand, she is affronted with a voice that compels her to abide by the rules and permits her to enjoy some small reliefs (egress from a locked room) if she concedes to it. +On the other, she is always teeming and thus flirting with red, considering escape routes that involve drawing blood, setting off alarms, or removing clothes. + +This unruly red is what Macro Data Refinement's greening procedures are supposed to contain to produce a completely controlled and scripted blot of blue. +Perhaps this is why the glipse of the vacant desks planned for the severed floor's expansion are draped in purple, for that shade of subjectivity would better incorporate the contrasting contours into a unified and taskable tone. +The red that threatens Lumon's corporate, calm, and collected blue (the Lumon logo is a water droplet that suspiciously resembles a camera) is splattered across scenes in the episode. +It is, for example, the envelope that Petey slips Mark at the company-owned restaurant #emph[Pip's] with the suggestion that he should read it if he wants to know #quote[what's going on down there]. +It is the sweater Mark wears to his sister's dinnerless dinner party, punctuated by red place mats (#quote[what a lot of people overlook, I think, is that life is not food]), where the ontological substance of his innie is called into question, and where we learn about the passions he has lost-- the history of World War II, educating, whiskey-- the last of which seems to have given way to an indiscriminate consumption of beer, wine, anything that will drown out the clarity of sober consciousness. +It is the general hue of his sister's house, which consisently wants him to question that placid blue of his company-subsidized housing at Baird Creek Manor. + +This dinner tells us something more about the subject in question in #emph[Severance]. +Just as Helly's outie had alerted us to the basic principle in the video her innie was shown in curiously lo-fi resolution to conclude her innie's orientation-- #quote[perceptual chronologies… surgically split]-- Mark's predicament is comparably explained to him by another more or less ignorant (we can't help but imagine) third party: #quote[One's memories are bifurcated, so when you're at work, you have no recollection of what it is you do there.] +As pretentious as they are, the dinner's guests do seem to be attuned to an important dimension of the meaning of life, which is that it can't #emph[only] be about satiating biological needs such as food. +What each individual 'needs' is a disharmonious melange of needs and demands, openings of desire that emerge not only through a graph of bare necessities-- food, water, shelter-- but also through capricious carapaces that emerge from more ambiguous pinings in the social sphere-- company, care, love. +The real question of Lumon's smooth functioning is whether it will be able to effectively plug up these pinings, the incidental moments at work where one wonders what one is really doing with one's life, whether the company can really manage its employees' unsanctioned thoughts and the way in which those illicit ideas seep into the daily practice of their workerhood. +More on the plasticity of our needs and drives to satisfy them in later posts. + +#image("img/severance-s1e1-shot3.jpg") + +Ms. Cobel, in contrast to Helly's and Mark's doubtful and doubting red, is a stormy and icy blue. +(We must wait until season two to uncover the historical and psychological depth of this colour for Harmony Cobel.) +She is the figure with a body that seems to be the most in charge, of those we meet in this episode. +Though Ms. Cobel is not a master in herself, it seems, for she too is subjected to a disembodied voice-via-device, 'the board', albeit which only appears evidently as an ear so far (#quote[The board won't be contributing to this meeting vocally]). +Cobel is responsible for keeping the severed floor's uncertainty in check, the 'head' that sits atop the variegated limbs of its disobedient body. + +When Cobel reprimands Mark for his derailing of Helly's orientation, she recalls an obscure and theological aspect of her parentage: + +#quote(block: true)[ + You know, my mother was an atheist. + She used to say that there was good news and bad news about hell. + The good news is, hell is just the product of a morbid human imagination. + The bad news is, whatever humans can imagine, they can usually create. +] + +At the close of the episode, just before Mark's senile neighbor Mrs. Selvig (who we have only heard about through Mark's voice thus far, when he is on the phone with her) visually reveals herself to be the same woman as Ms. Cobel, she gives a slightly different account of her heritage: + +#quote(block: true)[ + You know, my mother was a Catholic. + She used to say it takes the saints eight hours to bless a sleeping child. + I hope you aren't rushing the saints. +] + +It's unclear at this point whether Cobel is a severed worker like Mark, or whether there is some other reason for her (strange, almost senseless) duplicity. +Why lie about the religious leanings of one's mother? +Or maybe 'mother' is actually a name for something else, a kind of interim authority that gives synthetic weight to some hearsay, rumor, or idle phrase. +(The other cameo of an ambiguously defined mother in this episode is in question five of Helly's orientation survey: #quote[To the best of your memory, what is or was the color of your mother's eyes?]) +Perhaps it is that, severed or not, atheist or Catholic, Cobel's subjectivity is structured by a comparable split in her perceptual chronologies, whereby some memories (of her mother) get more airtime in her conscious experience of herself than others. + +#emph[Severance] flirts with this idea extensively, that the innie/outie dyad is analagous to the unconscious/conscious experience that we, as subjects, have of ourselves. +Mark's sister Devon hints at the psycho-logical reading of the severed condition in her diagnosis of Mark's morose (outie) predicament as a state of failed therapy in response to mourning for his late wife: #quote[I just feel like forgetting about her for eight hours a day isn't the same thing as healing.] +As with not-mothers and the plasticity of the drive, we will address the psychoanalytic implications here in later posts; but to finish I want to bring our attention to the #emph[imaging of time] at work in just this first episode. + +The fascinating details of failed synchronisation between all the watchfaces we see are enumerated in #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/txxbcm/observation_and_question_regarding_time/")[this Reddit thread]. +Many of the watch hands appear to be stalled, and the crossover from each to the next-- as when Mark Scout switches his wrist watch in preparation for his elevator descent into the workday of innie Mark S-- doesn't match with our experience of the actors on screen. +One of the few things we do know about the severance procedure is that it 'alters perceptual chronologies', and that this messing with a subject's sense of time is thought to + ++ make them more adequate or productive in a certain kind of work (for why else would Lumon go to the necessary lengths to sever some employees) ++ supposes to section off innie memories and experience from outie memories and experience + +So the subject's subjectivity is marked by its sense of time, and Lumon's success (profitability?) hinges in some way on altering their employees' stable sense of it while in the space of the severed floor. + +Mark S's temporal predicament here has been explained by a man whose last name we get by speeding up the saying of his own, Karl Marx (Mar-k-S). +Logically speaking, Marx argues, there is an amount of time that goes missing in the worker's employment by way of a wage, when he advances some portion of his time to the capitalist in exchange for a pay-check one or more weeks later. +I refer the reader interested in the details to #link("https://www.marxists.org/archive/marx/works/1867-c1/ch20.htm")[chapter 20 of #emph[Capital] Vol. I];: but the essential point here is that it is through an obfuscation of the real value of a worker's time that the capitalist manages to produce surplus-value. +The production of this kind of time-distorted surplus-value is the engine of capitalism as a social relation that appears, on the surface, to be equally fair to capitalist and worker alike. +So the project of controlling 'perceptual chronologies' with which Lumon seems to be so concerned is perhaps not as esoteric and inessential as it might at first seem. Perhaps it is an embodiment of the core ingredient of the company's success as a company, of its incorporation as an entity that ought to be sustained even at the expense of its members' happiness, their health, and their livelihoods. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-2.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-2.typ new file mode 100644 index 0000000..1fcc98b --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-2.typ @@ -0,0 +1,122 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 2 + +#import "index.typ": template +#show: template + +#set document(title: [Half Loop - _Severance_ [s1/e2]]) + +#title() + +In #link("./severance-ep-1.typ")[the first episode], we were introduced to the two-sided subject at Lumon. +On the one hand, there is Mark S, the innie, who is screened for the first and major part of the episode. +On the other, Mark Scout, the outie, to whose predicament we are introduced in the concluding scenes. +S1E2 opens with a rewind on how innie Helly R came to be: how Milchick handed her flowers at end of her first day (which we glimpsed in S1E1 when Mark almost ran her over), a glimpse of her confidence gliding into the operating room on a higher floor of the same Lumon complex we saw Mark leave, a stereoscopic view of the implant procedure by which she becomes an android whose existence is #quote[spatially dictated] by Lumon's mysterious machinations. + += Lumon Industries + +#image("img/severance-s1e2-lumon-logo.png") + +Lumon is a corporate pastiche, and not only of technology companies. +Lumon seems to have its hands in surgical hardware (the operating room equipment), digital technology ('Macrodata Refinement'), and medicines and topical salves (as discussed at the dinner party in S1E1 - #quote[What don\'t they make?]). +It is a quintessentially American jack of all trades, a global power in its own right cohered by a family dynasty---the Eagans---recalling the Du Ponts or the Rockefellers. + +The more obvious comparison to make, however, is between #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/1fb28nq/apple_lumon_are_weridly_similar/")[Lumon and Apple], perhaps in part because the show screens on Apple TV Plus. +The style of the computers on the severed floor recalls #link("https://www.historytools.org/docs/computer-history-timeline-personal-computers-computing-internet")[the dawn of the era of personal computing] in the 1970s and 80s, an aesthetic imaginary in which Apple plays an important role. + +Indeed, the aura of Lumon as a futuristic computing corporation from the late 70s is reinforced by the fact that its headquarters are shot at #link("https://ethw.org/Bell_Labs")[Bell Labs] in New Jersey, a building that has now been renovated as a mixed-use office for high-tech startup companies as #link("https://en.wikipedia.org/wiki/Bell_Labs_Holmdel_Complex")[Bell Works]. +Bell Labs is the quasi-mythological source in the contemporary corporate technology culture (Silicon Valley) of the idea that a certain kind of research freedom characterized by open-ended product delivery timelines and serendipitous encounters in open office plans can cultivate ground-breaking technology. +(Mark Zuckerberg #link("https://www.businessinsider.com/mark-zuckerberg-recommends-the-idea-factory-2015-11")[recommended] a book on Bell Labs as one of his #quote[important books] of 2015.) +The irony of this setting, of course, both in _Severance_ and in the technology companies it parodies in the American landscape in 2025, is that the workplace has never been more saturated with surveillance and micro-management. +The overhead shot of Helly R that opened the series is indicative here again, as is the complementary overhead of MDR's desks we get in this episode: there is always something watching from above, it seems, even if what it captures of the actual activity is a flattened and at times misrepresentative image. + +There are also evocations of Microsoft and IBM in Lumon, such as the #link("https://en.wikipedia.org/wiki/Office_Assistant")[Clippy];-like guide on the manual handed to Helly in the episode, or the apparent requirement of suits on the severed floor echoing #link("https://www.reddit.com/r/AskHistorians/comments/7l9ncw/comment/drkzual/")[IBM's infamous strict dress code]. +Lumon is a melange of imaginary pasts, presents, and futures in American innovation. +It is futuristic in the framing of its bio-technological project of perceptual management---and in the #quote[data smuggling] detectors that are installed in the elevators to the severed floor, about which more soon---but retrofitted in its aesthetic, in its management style, and in its outdated repertoire of daily devices. +Recall, for example, Milchick's handheld camcorder, and the tube-activated (vacuum-tube?) camera he uses to snap the official photo +of the new group of refiners. + +#image("img/severance-s1e2-bell-works.png") + +The overhead of Lumon Industries itself depicts a sketchy graph of a brain, one can't help but think. +Its upper floors all operate above board with normally conscious workers, whereas underground there is something sensitive enough happening so as to require extra precautions. +In #link("./severance-ep-1.typ")[S1E1's analysis], we introduced the idea that Lumon's interest in severing workers has to do with the mechanics of capital, in that surplus value can only ever be produced (in Marx's account) through the structural theft of time from its laborers.#footnote[#link("https://fi2.zrc-sazu.si/en/sodelavci/bostjan-nedoh-en")[Boštjan Nedoh] has evocatively called this operation #quote[theft without a thief].] +Lumon's spatial layout suggests that there might also be a psychoanalytic metaphor at stake in severance as an operation, where the happenings that occur in the business brain's basement are essential to what it really is, why it does what it does. + +Though Freud's theory has been popularized as a topographical notion, wherein the unconscious is the submerged part of the mind's iceberg of which we only see the tip, there is good reason to believe that this spatial description misrepresents how the unconscious should be properly understood. +Lacan thus preferred _topological_ descriptors to suggest that, if the unconscious is a 'place' or 'site', it contradicts any over-simplistic understanding of spaces that are distinctly separable. +The relationship between the conscious and the unconscious in a psychoanalytic theory of the subject, I would suggest, is better understood through the figure of a coin with two inseparable sides. +The meaning of any one side ('heads') derives from the meaning of its opposite ('tails'); and it is thus insensible to imagine separating one part from the other without repressing something fundamental about the structure of the subject as a whole. + +Lumon, though, seems to want desperately to keep innies from being in contact with their outies. +Indeed, the very project of severance seems to have something fundamental to do with managing repression effectively, with renovating the worker into a perfectly divided self that cannot complain about the conditions of her labor through the fact of not knowing anything about them. +(When Mark is given a dinner coupon on account of his head injury in S1E1, the real cause of the scar---Helly R's riotous attempt to escape the orientation room---is not revealed to outie Mark.) +The subject in _Severance_ is split and maintained as such. +The 'unconscious' of one's home life should not affect one's 'conscious' ability to perform at work. + +The vice-versa is also true. +Outies cannot suffer the 'unconscious' of their innies, either. +Mark Scout's decision to sever himself seems to be an attempt to repress the devastating effect of his experience of his wife's death for some part of the day, given that he admits he was unable to continue his job as a history teacher due to alcoholism. +At Lumon, however, Mark's alcoholism is brutally functional; as his innie must suffer what (lack of) energy he is given by outie Mark's actions the night before (#quote[I find it helps to focus on the effects of sleep since we don't actually get to experience it]). + +The intellectual impoverishment of Lumon's severed workers is further exposed in this episode as Dylan tries to convey to Helly the substance of what there is to live for as a severed innie: his #quote[embarrassment of wealth] that consists of finger traps, a caricature portrait, and the hope that there might be a #quote[waffle party] on the horizon. +The sad satisfactions that severed workers aspire to reinvigorate the sense of the phrase #quote[wage slavery], an important formulation that in fact has solid footing in Marx's analysis of capital. +For Marx, it is worth comparing the wage worker's predicament to the slave's; for both must labor not for themselves, for their own ends and aspirations, but for an external master that appropriates their efforts. +The important distinction is that, while in _actual_ slavery the slave's enthrallment to the master is explicit and explicitly enforced by means of force, in _wage_ slavery the figure of the master is more diffuse, and hierarchical distinctions are 'justified' in the discursive suggestion of their being fairly and freely established. +The proletariat (wage laborer) is free to choose her own master on the market, selling her labor power to whomever she chooses. +But she is not free to refuse to sell her labor as labor-power; as this #quote[wage slavery] is the generalized means of her reproduction and ability to go on living. +So the proletariat is enslaved to a structure, not a person, and that structure is characterized by the reduction of labor in its multifarious forms to labor-power, a measurement of labor in time that thus becomes exchangeable on the market. +In capitalism, in other words, freedom is structurally reduced to the freedom to choose to whom one sells one labor-power: which is #emph[not] the same thing as freedom tout court. +Thus is the wage laborer unfree in a way that is comparable, though not equivalent, to the slave. + += Death at Lumon +The death culture at Lumon should also be doubly refracted through Marx's analysis of how capital reduces its workers to shadows of themselves on the one hand, and a psychoanalytic understanding of the subject on the other. +When Mark gets emotional about Petey's disappearance during the game of office introductions (which tellingly involves passing around a brignt red ball), Milchick reprimands him with the following explanation: + +#quote(block: true)[ + I think this is a good time to remind ourselves that things like deaths happen outside of here. + Not here. + A life at Lumon is protected from such things. + And I think a great potential response to that from all of you is gratitude. +] + +Severed workers are insulated from death because the very structure of their subjectivity distances the meaning of its concept. +Innies symbolically 'die' when their outies do not come back to work, but this event does not necessarily coincide with their physical death, which as Milchick suggests should only be imagined to take place in the world of their outies. +There is a contradiction here, though, as a physical accident at work would propagate through to an innie's outie. +So Milchick's repression of the notion of death must be recognized as just that: a repression of a certain moment in or dimension of logic (a moment that is too dangerous or frightening to imagine saying out loud), and not as an explication of the necessary consequences of a thorough logic of life. + +Milchick's philosophizing also points to something more sinister in the structure of the severed subject. +The severed worker is protected from death, perhaps, because there is a sense in which he is already #emph[undead]. +Doomed to exist in the artificial enclosure of Lumon's basement and placated only by the pathetic enjoyments of finger traps, company coffee, ideological art, and the odd waffle party, what is there, _really_, to live for at Lumon? +The motto briefly shown on the implant hardware in Helly's operating room scene at the episode's opening has a morbid resonance here: #quote[Don't live to work. Work to live.] + +There is a stronger psychoanalytic sense in which we might make sense of Milchick's discourse on death that is worth mentioning here, too. +Lacan articulates a distinction between two kinds of death in his theory of the subject, a first death that is #strong[biological] and a second death that is #strong[symbolic]. +I will explicate this theory later in S1, when Milchick's foreshadowing of death's importance in the show bubbles clearly to the surface in a later episode. + += Capturing and controlling the symbolic +Let's talk about the #quote[symbol detectors] in the elevators, which are introduced in this episode. +These are the real basis of how Lumon separates innies from outies, as they supposedly ensure that no notes, no language, is passed between the two kinds of self. +In S1E1, we saw outie Mark put the tissue he had been crying into in his car in his pocket; and we then saw innie Mark confidently strolling out of the elevator on the severed floor, quizzically discovering the tissue in his pocket, and tossing it into a bin on his walk down the hall to MDR. +So the suggestion has already been planted in our (the viewers') mind that it is #emph[possible] to traffic objects across the boundary. +The other clear evidence of this is offered here in S1E2, where Irving similarly, quizzically, observes the black sooty substance underneath his fingernails during the distraction of the melon party. + +Yet Helly's note to herself triggers the alarms, resulting in the elevator doors refusing to close and a screen washed out with red alert. +So they do seem to have some power to detect 'symbols'. +But what marks the boundary between a symbol and a non-symbol for this technology? +It is not only explicit language in the form of written or spoken words that make meaning for us as human animals. +We are affected by a frightening range of other things; colors, tactile memories, qualities of our past selves that seep into our present (such as too much alcohol drunk the night before). +So it is hard to imagine, knowing the complexities of our selves as we all do, that Lumon could really effectively police the boundary between innies and outies, even with its back-to-the-future technological prowess. + +Indeed, the audio recording that innie/outie Petey shows outie Mark in his hideout at the greenhouse reveals the insecurity of symbol detection at Lumon. +In order to get a recording of what he was subjected to in the Break Room, he must have been able to get that retro handheld device back up into the 'real' world. +So either the elevators weren't able to pick it up, or there is some other way for innies to move between the supposedly demarcated spaces. +Either way, the symbol policing at the innie/outie border seems to have some shortcomings. + +A brief note on Petey's dishevelled greenhouse to conclude, as this episode is where we are first introduced to much of the geography that will become important in the series: the break room, wellness, MDR, optics and design, Mark's basement, the company restaurant (where Mark has his insufferably awkward date), the elevator, the MDR kitchen, the operating room, the Lumon foyer. +Petey's greenhouse, like many of the spaces in #emph[Severance], is a graph that both embodies and reflects a psycho-social moment of the show. +Green like Macrodata Refinement, but much less put-together, the greenhouse reveals the underside of Lumon's apparent glaem, the unconscious damage that its project of perfection wreaks on its workers psychologically and physically. +Petey shows us that the worker, like so many words and things in the show, is not simply what it seems, but consists also of an excess signification that inevitably creeps into its conspicuous comportment. +Mark is a depressed drunkard on the outside, and Irving (it seems) has his fingers in some hellish kind of black pie, a color that takes over his desk as he dozes off when he lets the distinction between his waking and unconscious self slip, we might say, when the reality of sleep threatens the security of being awake. +There is, as the imagery in the poster of the 'Whole Mind Collective' that motivates Mark to bunk off and follow up on Petey's enigmatic red letter suggests, a real revolution of sorts brewing beneath the surface of a fantasy of symbolic control. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-3.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-3.typ new file mode 100644 index 0000000..8c1f032 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-3.typ @@ -0,0 +1,197 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 3 with images + +#import "index.typ": template +#show: template + +#set document(title: [In Perpetuity - #emph[Severance] [s1/e3]]) + +#title() + +#image("img/severance-s1e3-shot1.png") + += We need to talk about Ms. Cobel +As we noted in #link("./severance-ep-1.typ")[analysis of S1E1], she typically storms the screen with an icy blue, a temper (the significance of this word we shall unpack shortly) that seeks to quell the fiery red that flickers in and out of the consciousness of workers on the severed floor. +The ominous ending to that first episode intimated that, while her wintery business has its office underground, it also warrants her prying into Mark's outie's personal life in Baird Creek's subsidized Lumon housing. +Indeed, it seems that Miss Cobel lives in Mark's housing complex, too. +From the state of her fridge, though, which we see in the foreground of a shot that implies surreptitious surveillance at work in her intimate space-- a sense that has already been produced in Mark's home with objects littered in the frame's foreground-- it doesn't appear that she spends very much time making a home there. +(Not too unlike Mark, perhaps.) + +Ms. Cobel is a kaleidoscopic vector of strange femininity in the show. +She is at once old widow next door, a girl-boss superior on the severed floor, and a little girl prone to tantrums. +As Mrs. Selvig, the hare-brained widow next door, she offers Mark unwanted company and cruddy cookies. +Yet we know by now that this is apparanetly a ruse, a senile disguise through which the conniving Harmony Cobel can keep an eye on her employee, Mark, beyond the bounds of his time at work. +At Baird Creek, she is a middle-aged executive in the clothing of an older and less cognitively composed character. + +But even if Ms. Cobel is the 'real' Mrs. Selvig, there is still something anile about her character. +She can be both comandeering and childish, as we see in her encounter with (innie) Mark S when he arrives unannounced at her office to request a kind of permission to take Hellie to the perpetuity wing in S1E3. +Commandering, because she accosts Mark with bureaucratic demands in her role as his boss (#quote[And have you filled +out a common-reservation slip?]). +Childish, because she literally throws a mug at him out of a petty frustration that is unbefitting of a mature manager. +Cobel rationalizes her childish temper as follows: + +#quote(block: true)[ + What I just did was something I knew that you could handle and grow from. + It was very painful for me. + I hope that you'll let it help you. +] + +This outburst locates something undecided within Ms. Cobel, a moment in relation to Mark where she lets her personal anger supersede her role as his manager. +This mug-throwing episode demonstrates that Cobel, too, is capable of breaking character as head of the severed floor and allowing some other aspect of her self to seep in, despite the pretense of a calm composure. +The thrown-mug, in other words, is the wish fulfillment heralded by Cobel's stunningly funny, inappropriate remark to Hellie during her orientation in S1E1; #quote[I've wanted to pummel Mark myself, but I am his employer.] +Even Cobel, who is supposed to be more in charge of herself than the MDR employees who are her inferiors-- her breaking into Mark's house while he isn't there implies is that she is unsevered, and thus more 'responsible'-- harbours desires that exceed and contradict the prescribed role she is supposed to play. + +The image of Cobel above confirms her as childish in some respects. +Notice that here, at 'home', she wears her hair in pigtails rather than loosely around her shoulders. +But it also paints her as a scopophilic and overbearing #emph[mother]. +Whatever she is doing creating excuses to talk to Mark's outie as Mrs. Selvig, it becomes clear in this episode that there is a convoluted kind of care at stake in her creepy and overcurious work. +Peering at him as he wanders up from the basement (Cobel doesn't seem to know that Petey is also down there at this point, though her break-in later in the episode suggests that she suspects something is awry), she murmurs to herself, #quote[Oh, Mark. Are you all right?] + +This is a strange exhibition of affection, coming from the same woman who will throw a mug at Markfor his failure to #quote[get MDR to its numbers] as department chief, who knowingly subjects him to the break room-- which we observe on screen for the first time later in the episode-- and who steals the book left by his brother-in-law as a gift at his doorstep. +Despite these mistreatments, Cobel does still seem to hold some perverted penchant for and attachment to Mark. +As HaxDogma notes in #link("https://www.youtube.com/watch?v=JAhhVnevSm4")[his review of this episode], it is hard to see Mark's promotion to department chief after Petey disappears as anything other than a nepotistic appointment, given that Irving is clearly the more experienced refiner in a number of respects (orientation procedure, group photo protocol, number of years spent on the severed floor, to name a few). +Cobel's overinquisitive manner on display in this episode is perhap best described as motherly, even as she is certainly not a paradigmatically #emph[good] mother. + +There is also something undoubtedly sexual about Cobel's relationship to Mark. +Her lingering at the door in S1E2 waiting to be invited in, her awkward and suggestive mention of her late husband's building an apartment in the back of their abode in heaven #quote[in case I found a new man before I got there], her creating an excuse to talk to him by pretending to de-ice her stoop; and, naturally, her peeping at him through the window. +She is either a stalker by-the-book, or (more charitably) a lonely woman who is searching for some missing satisfaction. +Most likely, she is an inextricable concoction of the two. +Cobel wants to have Mark's cake and eat it too; to be at once his mother, his corporate superior, and (we can't help but suspect) his lover. +Like many put in positions of power, she has trouble setting her more inapproriate desires aside so as to simply 'do her job'. + += Primal father figures +Cobel's mother energy is arguably muted and mixed up in her #link("https://en.wikipedia.org/wiki/Sphinx#Riddle_of_the_Sphinx")[Sphinxesque] triplicity. +But the father energy on display in this episode is, by contrast, loudly and proudly pronounced in at least three different figures: Petey, Irving, and, of course, Kier Eagan. +#footnote[ + There is foreshadowing, too, of a fourth father figure in Rickon, Mark's brother-in-law. + While reading his confiscated book, Milchick quietly remarks to himself a thought that will become an important refrain for many other characters with respect to Rickon later in the season: #quote[This is… Jesus.] +] +Before tackling these fathers one by one, it is instructive to straightforwardly and schematically lay out the #strong[Oedipus complex], an 'absolute fiction' that nonetheless, Freud claims, depicts something foundational about the graph of the speaking subject, the graph in which we took interest in #link("./severance-ep-1.typ")[our analysis of S1E1]. + +The Oedipus complex is so-named because it takes its architecture from the figure of Oedipus as he appears in the ancient Greek playwright #link("https://www.cliffsnotes.com/literature/o/the-oedipus-trilogy/about-the-oedipus-trilogy")[Sophocles' trilogy], which consists of the plays #emph[Oedipus Rex], #emph[Oedipus at Colonus], and #emph[Antigone]. +(Oedipus' tragic tale is drawn from a mythology that predates these plays, but the story is nonetheless usually traced to its Sophoclean production.) +Oedipus is well-known to students of psychoanalysis because of Freud's making him into a #link("https://nosubject.com/Oedipus_complex")[complex], which is generally (mis)understood as 'every person wants to kill their father and fuck their mother'. +Famously, Oedipus killed his father-- at a crossroads, thinking he was simply a threatening stranger at the time-- and married his mother-- not understanding that relation in the moment of the act, either. + +Jacques Lacan rendered the Oedipus complex more philosophically significant than this overblown and crude Freudian telling. +For Lacan, the Oedipus complex designates an abstract account of how desire is produced by the speaking subject in relation to the formative figures with which it is in relation. +As he notes in one of his 1938 text, #emph[The Family Complexes]: + +#quote(block: true)[ +our criticism since Freud presents this psychological entity [the Oedipus complex] as #emph[the specific form of the human family] and subordinates all social variations of the family to it. @lacanFamilyComplexesFormation2002[p.35] +] + +The Oedipus complex is not so much a diagnosis of a particular perversion that is presumed universal, in the sense that everyone #emph[consciously] suffers by repressing these secret dual desires to kill (my father) and to fuck (my mother). +It is rather an important part of how he architects a philosophy of the subject's relation to itself (and others) by way of a #quote[triangular conflict] @lacanFamilyComplexesFormation2002[p.41] between three figures: one's self, the Mother, and the Father. + +The Mother is the subject's first known object that is seen as separable from one's sense of self. +We can imagine this through the process of weaning, of a mother teaching her baby that sustenance ought to be sought in solid foods rather than directly from her teat. +Originally, a baby does not have a firm enough sense of itself to recognize that the Mother's teat is separated from its own body. +When it wants nourishment, it cries, and a breast brimming with milk appears (assuming a good mother, here). +The breast seems almost part and parcel of the baby, from its perspective, as what reason does it have to think otherwise? +(We are assuming here that the separation between a baby's sense of its own body and the world is not ingrained at birth, but rather learned, acculturated.) +It is only when the baby's crying stops precipitating a breast that it should start to doubt this part of itself, to think that perhaps my Mother's breast is not part of #emph[me] as subject but rather its own kind of thing, a separate object. +Thus the Mother is, in this developmental sense, the subject's first #emph[proper] object. +The Mother (and her breast), the baby subject thinks, is both mine and not mine, as though there is some #emph[relation] that my Mother has to me, she is not (quite) the same as me. + +The Father, on the other hand, incorporates (into) the baby subject's sense of self differently. +It is not considered, as the Mother is, a part of the subject that was at some point taken away, but rather represents the source of that action of taking away. +If the Mother #emph[ought] (in the terms of the baby subject's nascent ethics) to be a part of me, the Father is the force and figure responsible for taking her away. +This stature of the Father is better understood, perhaps, with reference to the myth of the #strong[Primal Father], which Lacan reinterprets from its presentation in Freud as originally depicted in the fourth and final chapter of #emph[Totem and Taboo] @freudTotemTabooResemblances1919. +Like the Oedipus complex, the myth of the Primal Father is a narrativization that helps to understand the structure of the subject. +Suppose a primal horde, Freud offers, at the helm of which exists a Primal Father who monopolizes all women. +All women in the horde, in other words, are sexually subject to this single male; no other male gets to enjoy anything of them. +A band of brothers, resentful of the Father's monopoly on enjoyment, conspire to escape the ban on sexual enjoyment through a plot to murder him. +#footnote[ + There has been much written on Freud's mythos of the Primal Father. + For a relatively recent use of the concept that serves as a reasonable introduction to Lacan's reading of #emph[Totem and Taboo], see @mcgowanDistributionEnjoyment2021. +] +They do so through what could be called an original jealousy, a feeling that the Father is enjoying in a way that is prohibited (by virtue of the Father's taboo) for each of them. + +Freud offers this as an #quote[historic explanation… [of] the origin of incest] @freudTotemTabooResemblances1919[p.207], as the Primal Father's taboo on enjoyment is what, Freud suggests, drives exogamy, wherein each of the band of brothers leaves that original tribe to start their own in which they can (finally) enjoy the women for themselves. +That this is an historic explanation does not mean that Freud believes that it represents an actual state of affairs in some distant past. +Indeed, he states the opposite, that #quote[primal state of society has nowhere been observed.] @freudTotemTabooResemblances1919[p.233] +The parable of the Primal Father is historic rather in the sense that narrates to us an important aspect of the structure of the subject, much like Oedipus' tragedy. + += Daddy issues at work + +Okay: we now return from this Freudian digression to the stuff of #emph[Severance]. +What bearing do the Oedipus complex and the myth of the Primal Father have on the structure of the subject on display in the show? +Let's go now to the scene in S1E3 at the crossroads, where MDR runs into two employees in Optics and Design (O&D). + +#image("img/severance-s1e3-shot2.png") + +The composition of this shot puts the reflective axis down the center, and the encounter is suggestively Oedipean in its structure (at a crossroads, unknowing of the Other at play). +Note that Irving is compositionally mirrored by Burt, played by Christopher Walken, and we will explore this suggestive symmetry in detail in later episodes. +The two departments (MDR and O&D) know #emph[of] each other, we surmise from the dialogue that follows. +But Irving isn't supposed to know Burt by name, as he accidentally happened upon him in S1E2 on the way to a Wellness session. +(Burt was coming #emph[from] his Wellness session.) + +While Irving greets Burt on the back of this previous encounter with gentle and flirtatious warmth, Dylan's hostility towards O&D is clear. +In place of the camaraderie that one might have hoped for between the two factions given their shared plight as severed workers, there appears to be an enmity built on a mythology (what Irving calls an #quote[absolute fiction]) of otherness: + +#quote(block: true)[ + Kier sorted the departments by virtue. + Macrodats are clever and true, while O&D's more cruelty-centered…. + O&D tried a violent coup on the others decades ago, and that's why they reduced them down to two. + And that's why they keep us all so far apart now. +] + +Kier is evidently the Primal Father of the severed floor, responsible for instituting the symbolic system of rules, regulations, and affects in the various 'bands of brothers' which reside there. +The tour of the perfect replica of Kier's house later in the episode reinforces his architectural status as Primal Father. +Irving chides Mark for his lack of reverence in deigning to turn the tour of the Perpetuity Wing into Eagan Bingo, and is aghast when he almost happens to #quote[bed sit] on the facsimile in his duplicate chambers. +(Thou shall not lie in Kier Eagan's bed.) +Kier and the lineage of Eagans more generally constitute the #link("https://nosubject.com/Law_of_the_father")[law of the father], the signifier of authority that keeps the severed floor's social order intact, the symbolic source from which both rules and the forbidden temptations of their being broken, taboos, sprout. Irving fosters this authority during the tour, standing in for the absent caregivers, existential (Kier, the Eagans) and material (Cobel and Milchick as superintendents who seem to be letting the kids take care of themselves for a short period). + +Another paternal authority whose absence has haunted and structured Mark since the show's opening is Petey, the man whose shoes he stepped into as MDR's department chief. +As per his exchange to Cobel in the mug-throwing scene, Mark lionizes Petey as a tone-setter, often acting through an ethics refracted by the subordinate conjunctive, 'if Petey were here', or the preface 'Petey used to say'. +Mark's innie is steered more by an imagined sense of what Petey would do, rather than what Kier would. + +Thus while it is Cobel who is explicitly in charge, the spectral presence of these father figures-- Kier, Petey, Irving-- correlatively structures the subject on the severed floor. +There is, in other words, an Oedipal triangular conflict at work in relation the ethical imperative of a severed worker. +The four members of MDR, as orientations to the structure of this subject, suffer different relationships to the positions of Mother and Father. +Mark S is a momma's boy, sired more by Petey's radical rejection of company policy than by Kier. +Dylan, though impertinent to the minutiae in the structure of Law at times, is ultimately his Father's son, acquiring satisfaction by accumulating accolades, and apparently driven by the impending idea of another finger trap or a waffle party. +Irving seems at this point the most mature of the children, looking reverentailly to Kier. +Yet recall that he has been chided by Milchick already for falling asleep on the job, so not all is perfect in paradise. +Hellie has no time for Cobel's authority, yet we will see in due course that her relationship with a Father is a deep lineament in her personality, too. + += Taming tempers +The count of four in the members of MDR mirrors the exact amount of tempers that we learn about from Kier Eagan's wax simulacrum speaking during the tour of the Perpetuity Wing. +These tempers are crucial as coordinates of the Eaganic attempt to coherently quantify the subject, and Kier's pronouncement is deeply significant for our investigation of the subject's distorted structure on the severed floor: + +#quote(block: true)[ + I know that death is near upon me, because people have begun to ask what I see as my life's great achievement. + They wish to know how they should remember me as I rot. + In my life, I have identified four components, which I call tempers, from which are derived every human soul. + #strong[Woe. Frolic. Dread. Malice]. + Each man's character is defined by the precise ratio that resides in him. + I walked into the cave of my own mind, and there I tamed them. + Should you tame the tempers as I did mine, then the world shall become but your appendage. + It is this great and consecrated power that I hope to pass on to all of you, my children. +] + +If there was any doubt that Kier Eagan embodies the Freudian Primal Father, the foundational component of absolute fiction on which the edifice of Law (the rules and taboos by which a subject is bound to abide) is constructed, the quotation above should put it to bed. +Kier's 'philosophy' seeks to conquer death by quantifying life, sorting its myriadic nature into a #quote[precise ratio] of character that can be counted (completely, it seems) in four distinct tempers. +Indeed, we saw the pictorial representation of this taming in s1e2, in the scene where Irving meets Burt: + +#image("img/severance-s1e3-tamingtempers.png") + +In the post-Platonic cave of his own mind, Kier is the master of his passions. +He admits no unconscious contours that sneak up on him unbeknowst in Freudian slips of the tongue or unwanted symptoms. +Indeed, the Eaganesque fantasy of the subject is one in which the necessary excess of language that psychoanalysis discovered does not exist. +Words are detected (via sensors in the elevator, say), controlled, managed. +Any psychoanalytic excess is, in Kier's project of a precisely rationalized subject, beaten out of language. +Excess meaning is 'tamed' as if it were a wild animal by a clear-headed, upstanding, divinely radiant visonary. +(As we will see, the position of primal power that Kier occupies here is sexually overbearing, too, as we might suspect from the Freudian analogy.) + +This episode ends with two scenes depicting the dark and bloody underside of Kier's waxen vision of the precisely quantified human subject. +The first is Helly's harrowing experience in the break room, a space where the unruly distance between words as they are uttered and the meaning they convey is thought to be stamped out, suffocated by the drudgery of debilitating repetition. +A subject will not exceed its authorized symbolization, the break room seems to want to claim. +The worker's unconscious will be tamed and ultimately made beholden to a regime of conscious rationality. +The second, and the closing scene of the epsiode, is Petey's psychotic demise at the convenience store, where he yells at wit's end: #quote[I need tokens so I can eat!] +Ravaged by the failure of his complete quantification inside Lumon, Petey seems no longer to have a firm footing in either his innie's or outie's reality. +Mark looks on from a distance as he collapses outside the store, escorted by police, attempting (it seems) to account for his disintegration. + +#bibliography("./references.bib", style: "chicago-author-date") diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/writing-in-typst.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/writing-in-typst.typ new file mode 100644 index 0000000..1861235 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashindex_full_stoptyp/writing-in-typst.typ @@ -0,0 +1,132 @@ +// @rheo:test +// @rheo:formats html +// @rheo:description Blog post with HTML video elements and custom functions + +#let video(path, width: "auto", height: "auto", controls: "true", autoplay: "false", loop: "false") = { + html.elem("video", attrs: ( + src: path, + width: width, + height: height, + controls: controls, + autoplay: autoplay, + loop: loop, + style: "max-width: 100%", + )) +} + += Writing in Typst | Hacking on Neovim with Claude +== What is a 'good' writing system? +I have been #link("https://www.ohrg.org/devonthink-part-i")[incrementally] #link("https://www.ohrg.org/devonthink-part-ii")[hacking] #link("https://www.ohrg.org/devonthink-part-iii")[on] my writing environment for some time now, since at least 2013 when I started seriously using computers in undergrad. +A couple of years ago, I migrated to Orgmode as the best markup syntax for my needs, and #link("https://www.ohrg.org/writing-setup")[wrote aa post about how Emacs and Orgmode serviced my writing needs]. + +Here's a summary of that post and the core tenets of what I consider an acceptable writing environment, parsed out over the five or so years I've been experimenting with one through grad school: + ++ *Flexible, powerful and distraction-free*. + In short, this means that the environment needs to be an extension to a modal editor in the terminal. + I started using a #link("https://carlosbecker.com/posts/ed/")[modal text editor] around 2018, and use a range of ergonomic keyboards in #link("https://www.ohrg.org/cycling-typing")[funky ways] that make using a mouse undesirable in most cases. + (The web browser is the one environment where I still get some mileage out of a mouse. + I do a lot with keyboard shorcuts via #link("https://vimium.github.io/")[Vimium], but there are still some contexts where it's just quicker or more comfortable to use a mouse.) + One of the main reasons that I settled on Orgmode rather than, say, Markdown at the time was because of its #link("https://orgmode.org/manual/Citations.html")[more standardized bibliographic management]. + ++ *Non-proprietary and sane markup format*. + Microsoft Word documents and Google Docs are great for a lot of things, but I refuse to rely on either of them as a primary format for all of the writing I do, as their formats are to hard to parse (to write custom software for) and bound to Microsoft's and Google's ecosystems respectively. + The ability to run Unix-style comands on a simple markup format from a terminal to search and replace, for example, is an essential. + Writing documents in a plain-text markup language also gives me the safety of knowing that, if it really came down to it, I could write my own parser and compilers. + My writing archive shouldn't strictly rely on some company's infrastructure to host, search, or otherwise make use of the thought it contains. + Using such a format also means that cross-platform editing is made simpler and possible. + (I run linux mostly, but still regrettably use Android as my phone's operating system.) + ++ *Multi-format export*. + #link("https://willcrichton.net/notes/portable-epubs/")[Most of the world's documents are still PDF]. + There's no getting away from needing to export writing as PDF in many cases-- for e-readers like #link("https://www.ohrg.org/using-two-remarkables")[the reMarkable that I use], or for submission to conferences. + But we increasingly read writing on a web page of some sort, and so I also need a workflow to export fully functional documents to HTML and CSS, too. + Other formats that are interesting if not essential include some kind of presentation file (PowerPoint, or better: just a website that has slideshow-like interactions), Markdown for rich formatting to copy somewhere, and plain text. + +I have up until very recently used Orgmode as my markup language of choice, exported them to PDF with exported them to PDF with #link("https://www.latex-project.org/")[latex], and exported them to HTML with #link("https://pandoc.org/")[pandoc]. +But I am very attached to the Neovim ecosystem for my code editing and writing, and so it was clunky to open up an Emacs installation (that I barely understood) exclusively to edit Orgmode. +So I switched to editing Orgmode in Neovim along with everything else, #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[using plugins] and #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[custom functions] to get towards the writing experience that I wanted. + +This has actually worked surprisingly well, but it has some sharp edges. +One of the more significant ones is that any time I want to produce anything more complicated than basic, formatted text with citations and footnotes-- for all of which pandoc transformations produce reasonable output in both HTML and PDF-- I need to start embedding LaTeX into Orgmode, and deal with the LaTeX toolchain / dependency management in order to compile a PDF. +Similarly, if I want to produce an interactive HTML document, I need to embed the source code directly in Orgmode and ensure that the export process handles dependencies and the like appropriately. + +Some of this is unavoidable. +If I want to run custom Javascript in a website that is well beyond the expressive capacities of a markup language, at some point I just want to be able to write Javascript. +But what I found frustrating about my Orgmode / LaTeX / HTML workflow is that there wasn't any reasonable way to work towards extending the markup language in _some_ ways, unless I was willing to start developing my own bespoke flavor of Orgmode plus plus. +I also don't particularly like wrestling with the LaTeX ecosystem, because-- and this is hardly controversial to say-- #link("https://tex.stackexchange.com/questions/222500/why-is-latex-so-complicated")[LaTeX has a lot of bloat]. +What I wanted was a more _extensible_ system which had saner defaults. + +== Enter: Typst + +A few months ago, I started seriously considering #link("https://typst.app/")[typst] as a potential replacement for LaTeX. +At the very least, I thought, it would be more fun to wrestle with a modern ecosystem when struggling to produce some custom table or figure in my output PDF, as typst has a #link("https://typst.app/docs/reference/layout/")[layout system] that uses terms that are a lot more intuitive to me than the black magic of laying out LaTeX documents. + +It just so happened, however, that I started to follow typst development more closely at a time when the final touches to the #link("https://github.com/typst/typst/issues/5512")[basic foundations of HTML export], such as footnotes and bibliography, were just about to be added to the upstream. +So I made #link("https://github.com/typst/typst/pulls?q=is%3Apr+author%3Abreezykermo+is%3Aclosed")[a few contributions] to spirit it along, and started more serious experimentation using typst as a unified way to produce _both_ PDF and HTML in my writing environment. +Pandoc #link("https://pandoc.org/MANUAL.html#typst")[can convert to and from typst], so I originally intended to keep writing documents in Orgmode and then transiently convert them to typst in order to produce PDF and HTML both. +But I quickly found that the typst syntax natively accommodates all of the features that I make use of regularly in Orgmode such as citations, footnotes, headings, links and text decoration-- and then some. + +So why not write my blogs, papers, and documents directly in typst? +I considered the critical features of my Neovim / Orgmode writing environment that I didn't want to abandon: + ++ *Shortcuts for markup*. + The #link("https://github.com/nvim-orgmode/orgmode")[nvim-orgmode plugin] makes writing Orgmode in Neovim pleasurable, providing shortcuts to insert a link and basic text decoration while composing. ++ *Citation and link picking*. + Though I've gone without it for a few months for reasons that are immaterial here, I used to have a shortcut to bring up a fuzzy finder for all of my bibliography entries to easily insert a citation. + The same fuzzy finder would make it easy to link to local files (in a website, for example, to link to other posts). ++ *Document folding*. + The ability to fold away all of the text beneath a heading is very useful when navigating larger documents, as it helps me to compartmentalize writing tasks and organize longer documents such as a dissertation chapter. ++ *Export shortcuts*. + I have customized my Neovim editor so that I can easily export the active Orgmode document (through the pandoc and LaTeX processes described above). + Personally, I don't feel that I need a real-time live preview of the document as I type, as I generally just want to check that it looks reasonable at certain junctures in the writing process, rather than continuously. + +The one other features of Orgmode that I have come to rely on heavily is its #link("https://orgmode.org/manual/TODO-Basics.html")[TODO functionality]. +I typically only use this in notes related to projects or tasks more generally, however, and not in documents that are intended for publication such as a paper or blog post. + +== Enter: Claude Code +At this point in the past of a new writing technology's prospecting, I would go searching for a Neovim plugin for typst and hope that it provides features that satisfy a majority of these requirements. +I've spent a fair bit of time #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua")[tinkering with my init.lua], the entrypoint for customizing Neovim, but I've never had the time nor interest to sit down and write a plugin from scratch. + +LLMs, of course, are at time of writing taking the coding world by storm. +I have started moderately relying on #link("https://github.com/anthropics/claude-code")[Claude Code] when writing some-- though certainly not all-- kinds of code. +As is well-known by now, Claude is especially good at scaffolding hacky scripts or modules from scratch, when no large codebase or domain-specific knowledge needs to be kept in context. +A Neovim plugin, I realized this morning, is a pretty ideal domain for LLM-assisted coding. +The 'codebase' is often just a single configuration file, and the domain-specific knowledge is the Neovim editor itself, a well-documented and expansively customized software for which there are many examples on Reddit.#footnote[It's impossible to mention LLM coding at this time without adding some sort of disclaimer that, no, I don't think AGI is around the corner, and yes, I do expect both programming languages and language writ large to remain 'a thing' in the foreseeable future. LLMs are an incredibly powerful tool to write and analyze code and text, but the purpose of code and text-- as a medium of symbolic communication amongst social beings-- has not been rendered valueless since ChatGPT became publically available. If anything, the value of adeptly and adroitly handling written language has taken deeper root. For my preliminary thoughts on why we are so keen to imagine that computers will supplant the usefulness of the human, I refer the reader to #link("https://caiml.org/dighum/announcements/digital-humanism-salon-capital-and-the-computer-by-lachlan-kermode-2024-06-24/")[this talk I gave in 2024].] + +So I fired up Claude Code earlier this afternoon, and-- fast-forward an hour or two-- I have a fully functional writing environment for Typst that essentially has feature-parity with my Orgmode environment. +Moreover, my #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim")[Neovim config] is now much more comprehensibly modularized; and I have a tried-and-tested method for extending it without needing to spend days learning the ins-and-outs of Neovim's API; and #link("https://github.com/breezykermo/nixos/commit/67cdbbae0dd77db766289b7f6eb278091ab937dd")[some bugbears in my NixOS config were eliminated] while I was at it. +(If that last bit means nothing to you, count yourself lucky!) + +== My new writing environment +I use #link("https://tree-sitter.github.io/tree-sitter/")[treesitter] for syntax highlighting, and Typst already looks pretty good with it. +I get function completion #link("https://github.com/breezykermo/nixos/blob/main/home-manager/server/neovim/lua/plugins/lsp.lua#L17-L24")[by integrating an LSP for the format], for which I'm using #link("https://github.com/Myriad-Dreamin/tinymist")[tinymist]. + +As I noted above, I haven't had dynamic link or citation insertion for some time. +It was one of the features that got lost in my move from writing Orgmode in Emacs to writing it in Neovim. +I use #link("https://github.com/nvim-telescope/telescope.nvim")[telescope.nvim] for general search and file-picking when coding in Neovim, and I figured that I could use a customized pop-up to dynamically pick available citations from the relevant #link("https://www.overleaf.com/learn/latex/Bibliography_management_with_bibtex")[BibTeX] file, too. +After a few minutes of #link("https://simonwillison.net/2025/Oct/7/vibe-engineering/")[vibe-engineering], I have the following: + +#video("../img/typst-links-citations-demo.mp4") + +When I am writing in Typst, and I want to bring in a reference, I can open a panel. +Note that the search is full-text, not just using the reference ID. +I also have a shortcut to specify which bib file to use through the `#bibliography` function in Typst. +I can insert links in the same way as citations, both references files relative to the current one (blog posts on the same site), and external links. +Both the citation and link insertion work either by highlighting text and annotating it, or to insert new links/citations. +I also have a similar shortcut to add footnotes. + +This is pretty functional now for generic writing! + +== Future work +Typst isn't ideal for producing fully-featured websites currently, as HTML export is experimental. +Even when it becomes better supported, the project is-- understandably, given its priority supporting PDF-- taking a #link("https://github.com/typst/typst/issues/5512")[relatively conservative approach] to HTML generation. +Anything that doesn't have a robust analog in a PDF document, such as videos and hover panels, will have to be 'embedded' in Typst with HTML/CSS/JS, rather than being written in Typst syntax. +The current experience isn't much worse than Orgmode with Pandoc, though, and the Typst roadmap promises that it will become much better in the relatively short-term future. + +There is a longstanding issue that I've had with links in Orgmode that I haven't yet tackled with Typst. +When I'm writing, I like hyperlinked text to appear as it will in the final document, i.e. without the underlying URL on display. +When editing any particular line, though, it's better that all of the links are 'expanded' to their full source syntax (`#link("...")[...]`) so that its feasible to edit the markup without requiring any fancy shortcuts. +The effective shortening of lines that occurs when hiding these URLS results in different Neovim line-wrapping requirements, with which the Orgmode plugin I have been using does a bad job, giving ugly linebreaks in documents with long links. +This link presentation will likely be the next feature I add to my Neovim Typst plugin. + +I'll add to the capabilities in #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim/lua/typst")[my Neovim config files], and might eventually release a separate plugin if the features become significant/mature enough. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot1.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot1.jpg new file mode 100644 index 0000000..15ec7c6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot1.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot2.png new file mode 100644 index 0000000..86ef4bd Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot3.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot3.jpg new file mode 100644 index 0000000..5748da6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot3.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-apple-II.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-apple-II.jpg new file mode 100644 index 0000000..34b4c3b Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-apple-II.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-bell-works.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-bell-works.png new file mode 100644 index 0000000..21af54e Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-bell-works.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-commodore-pet.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-commodore-pet.jpg new file mode 100644 index 0000000..a3a14cd Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-commodore-pet.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-ibm-pc.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-ibm-pc.jpg new file mode 100644 index 0000000..5fdd501 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-ibm-pc.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-lumon-logo.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-lumon-logo.png new file mode 100644 index 0000000..ef615ce Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-lumon-logo.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot1.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot1.png new file mode 100644 index 0000000..00d9360 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot1.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot2.png new file mode 100644 index 0000000..b0084f6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot1.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot1.png new file mode 100644 index 0000000..dfa281e Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot1.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot2.png new file mode 100644 index 0000000..ba39e37 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-tamingtempers.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-tamingtempers.png new file mode 100644 index 0000000..6eaac35 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-tamingtempers.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/typst-links-citations-demo.mp4 b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/typst-links-citations-demo.mp4 new file mode 100644 index 0000000..a29ea41 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/typst-links-citations-demo.mp4 differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/index.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/index.typ new file mode 100644 index 0000000..e80f8be --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/index.typ @@ -0,0 +1,37 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Main blog index page with post listings + +#let div(_class: "", ..body) = html.elem("div", attrs: (class: _class), ..body) +#let br() = html.elem("br") +#let hr() = html.elem("hr") +#let ul(_class: "", ..body) = html.elem("ul", attrs: (class: _class), ..body) +#let li(_class: "", ..body) = html.elem("li", attrs: (class: _class), ..body) + +#let template(doc) = { + doc + context if target() == "html" or target() == "epub" { + div[ + #br() + #hr() + #ul[ + #li[#link("./index.typ")[Home]] + #li[#link("https://lachlankermode.com")[Learn more] about me] + #li[#link("https://ohrg.org")[Read other musings]] + ] + ] + } +} + +#show: template + += Screening the subject + +_Screening the subject_ is a blog that analyses content on both the big and small screen in reasonable detail, i.e. episode-by-episode or scene-by-scene. +Contact us at #link("mailto:info@ohrg.org")[info\@ohrg.org] for enquiries. + +// Be alerted of new content by subscribing to the #link("https://screening-the-subject.ohrg.org/feed.xml")[RSS feed]. + +- #link("./severance-ep-1.typ")[Severance, s1/e1] +- #link("./severance-ep-2.typ")[Severance, s1/e2] +- #link("./severance-ep-3.typ")[Severance, s1/e3] diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/references.bib b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/references.bib new file mode 100644 index 0000000..f8543cd --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/references.bib @@ -0,0 +1,30 @@ +@article{freudTotemTabooResemblances1919, + title = {Totem and {{Taboo}}: {{Resemblances Between}} the {{Psychic Lives}} of {{Savages}} and {{Neurotics}}}, + author = {Freud, Sigmund}, + translator = {Brill, A.A}, + year = {1919}, + journal = {Moffat, Yard and Company}, + volume = {50}, + number = {1}, + pages = {94--95}, + publisher = {LWW}, + urldate = {2025-06-05} +} + +@misc{lacanFamilyComplexesFormation2002, + title = {Family {{Complexes}} in the {{Formation}} of the {{Individual}}}, + author = {Lacan, Jacques}, + year = {2002}, + publisher = {Antony Rowe London}, + urldate = {2025-05-22}, + file = {/home/lox/Zotero/storage/HAKMXWZ5/Lacan - 2002 - Family Complexes in the Formation of the Individual.pdf} +} + +@article{mcgowanDistributionEnjoyment2021, + title = {The {{Distribution}} of {{Enjoyment}}}, + author = {McGowan, Todd}, + year = {2021}, + journal = {European Journal of Psychoanalysis}, + volume = {8}, + number = {1} +} diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-1.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-1.typ new file mode 100644 index 0000000..cd00d88 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-1.typ @@ -0,0 +1,111 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post with images, footnotes, and bibliography + +#import "index.typ": template +#show: template + +#set document(title: [Good news about hell - #emph[Severance] [s1/e1]]) + +#title() + +#image("img/severance-s1e1-shot1.jpg") + +The first thing to notice is the colour palette. +She is dressed in blue, but her hair is chestnut red. +It spills out for the frame of her figure into the table around it, blockaded at its border by chairs and a carpet clad in green, yellow, then green again; then gray. +The establishing shot is a bird's eye view of an unknown woman who is soon revealed to have been put in the board room by someone else's design, who learns about her predicament only by a man's voice that emanates from the little device that rests on the table along with the woman, arranged so that it aims directly at her head. + +This opening image is a graph of the subject's predicament on the severed floor at Lumon. +Blue is the company colour. +Employees are almost invariably dressed in shades of it-- navy, midnight, Prussian, Oxford, cobalt-- and more reliably so as we work our way up the hierarchy. +Red is unruly passion, the tone of tempers that itch to tear off the straitjacket directives, to disregulate the business-as-usual in which there is no obvious place for illicit activities. +Green is the accent of Macro Data Refinement, the division of Lumon in which the show's protagonists are employed. +The device directs a man's voice at a woman's body in an attempt to keep her tempers in check, to ensure her firecraft does not smoke out the staid edifice of personality management, to order her #quote[perceptual chronologies] accordingly. +(Later in the episode, we learn that she almost manages to #quote[break in] on the control room during that opening sequence: the solidity of its enclosure is threatened from the very first.) + +It is instructive to attempt to articulate the dynamics that this graph indexes before we start talking about other scenes in the show. +Graphs are not at one with what they represent, for in the decision to render 'data' in the very act of a representation, we both lose and gain distinction of the dynamics in question. +The voice that opens Helly R up to the world of Lumon's severed floor begins: #quote[Who are you?] +This question is a mistake. +We retroactively learn, in a later scene, that Mark S was in fact supposed to begin with a less interrogative, more perfunctory: #quote[Hi there, you on the table. I wonder if you'd mind taking a brief survey.] +As Irving puts it: #quote[You [Mark S] skipped the preamble]. +Helly R is thrust, by this accident, immediately into questioning not only herself, but also the self-assurance of the voice that interrogates her. +Does this voice in my head [she could be thinking] really know what it is doing? +Or is it just a role of similarly confused actors struggling to stick to a badly written script? + +#link("https://www.youtube.com/watch?v=QIsLXuVeUgM")[This episode-length recap] of the first episode names this graph 'the Helly incident', a poorly executed orientation of Helly's newfound subjectivity that can be blamed at one level on Mark S (for starting with the wrong part of the manual), at another on Mr. Milchick (for misguiding Mark while he was distracted setting up the visual feed), on Ms. Cobel (for giving Mark Petey K's old manual without redacting his obscurely scribbled notes and paper bookmarks), or even on Irving (for neglecting to intervene and clarify how Mark should begin being the more senior refiner in the situation: #quote[Irving will be there to shadow. Just stick to the flowchart and escalate properly depending on dialectics.]). +Wherever to place blame, there is doubtless a misconfiguration that takes place. +Helly's instinctual reaction seems to be to try to kill the voice pointed at her head, rather than to befriend it as Mark states he did (where Petey was Mark). +(Helly will eventually have sex with the source of the voice, rather than murdering or fraternizing with it.) +In this episode, however, Mark (the voice's source) is physically assaulted by Helly, dented in his temple by the same vocalization device that mediated their first communication. + +#image("img/severance-s1e1-shot2.png") + +So this is the Macro Data refiner's situation. +On the one hand, she is affronted with a voice that compels her to abide by the rules and permits her to enjoy some small reliefs (egress from a locked room) if she concedes to it. +On the other, she is always teeming and thus flirting with red, considering escape routes that involve drawing blood, setting off alarms, or removing clothes. + +This unruly red is what Macro Data Refinement's greening procedures are supposed to contain to produce a completely controlled and scripted blot of blue. +Perhaps this is why the glipse of the vacant desks planned for the severed floor's expansion are draped in purple, for that shade of subjectivity would better incorporate the contrasting contours into a unified and taskable tone. +The red that threatens Lumon's corporate, calm, and collected blue (the Lumon logo is a water droplet that suspiciously resembles a camera) is splattered across scenes in the episode. +It is, for example, the envelope that Petey slips Mark at the company-owned restaurant #emph[Pip's] with the suggestion that he should read it if he wants to know #quote[what's going on down there]. +It is the sweater Mark wears to his sister's dinnerless dinner party, punctuated by red place mats (#quote[what a lot of people overlook, I think, is that life is not food]), where the ontological substance of his innie is called into question, and where we learn about the passions he has lost-- the history of World War II, educating, whiskey-- the last of which seems to have given way to an indiscriminate consumption of beer, wine, anything that will drown out the clarity of sober consciousness. +It is the general hue of his sister's house, which consisently wants him to question that placid blue of his company-subsidized housing at Baird Creek Manor. + +This dinner tells us something more about the subject in question in #emph[Severance]. +Just as Helly's outie had alerted us to the basic principle in the video her innie was shown in curiously lo-fi resolution to conclude her innie's orientation-- #quote[perceptual chronologies… surgically split]-- Mark's predicament is comparably explained to him by another more or less ignorant (we can't help but imagine) third party: #quote[One's memories are bifurcated, so when you're at work, you have no recollection of what it is you do there.] +As pretentious as they are, the dinner's guests do seem to be attuned to an important dimension of the meaning of life, which is that it can't #emph[only] be about satiating biological needs such as food. +What each individual 'needs' is a disharmonious melange of needs and demands, openings of desire that emerge not only through a graph of bare necessities-- food, water, shelter-- but also through capricious carapaces that emerge from more ambiguous pinings in the social sphere-- company, care, love. +The real question of Lumon's smooth functioning is whether it will be able to effectively plug up these pinings, the incidental moments at work where one wonders what one is really doing with one's life, whether the company can really manage its employees' unsanctioned thoughts and the way in which those illicit ideas seep into the daily practice of their workerhood. +More on the plasticity of our needs and drives to satisfy them in later posts. + +#image("img/severance-s1e1-shot3.jpg") + +Ms. Cobel, in contrast to Helly's and Mark's doubtful and doubting red, is a stormy and icy blue. +(We must wait until season two to uncover the historical and psychological depth of this colour for Harmony Cobel.) +She is the figure with a body that seems to be the most in charge, of those we meet in this episode. +Though Ms. Cobel is not a master in herself, it seems, for she too is subjected to a disembodied voice-via-device, 'the board', albeit which only appears evidently as an ear so far (#quote[The board won't be contributing to this meeting vocally]). +Cobel is responsible for keeping the severed floor's uncertainty in check, the 'head' that sits atop the variegated limbs of its disobedient body. + +When Cobel reprimands Mark for his derailing of Helly's orientation, she recalls an obscure and theological aspect of her parentage: + +#quote(block: true)[ + You know, my mother was an atheist. + She used to say that there was good news and bad news about hell. + The good news is, hell is just the product of a morbid human imagination. + The bad news is, whatever humans can imagine, they can usually create. +] + +At the close of the episode, just before Mark's senile neighbor Mrs. Selvig (who we have only heard about through Mark's voice thus far, when he is on the phone with her) visually reveals herself to be the same woman as Ms. Cobel, she gives a slightly different account of her heritage: + +#quote(block: true)[ + You know, my mother was a Catholic. + She used to say it takes the saints eight hours to bless a sleeping child. + I hope you aren't rushing the saints. +] + +It's unclear at this point whether Cobel is a severed worker like Mark, or whether there is some other reason for her (strange, almost senseless) duplicity. +Why lie about the religious leanings of one's mother? +Or maybe 'mother' is actually a name for something else, a kind of interim authority that gives synthetic weight to some hearsay, rumor, or idle phrase. +(The other cameo of an ambiguously defined mother in this episode is in question five of Helly's orientation survey: #quote[To the best of your memory, what is or was the color of your mother's eyes?]) +Perhaps it is that, severed or not, atheist or Catholic, Cobel's subjectivity is structured by a comparable split in her perceptual chronologies, whereby some memories (of her mother) get more airtime in her conscious experience of herself than others. + +#emph[Severance] flirts with this idea extensively, that the innie/outie dyad is analagous to the unconscious/conscious experience that we, as subjects, have of ourselves. +Mark's sister Devon hints at the psycho-logical reading of the severed condition in her diagnosis of Mark's morose (outie) predicament as a state of failed therapy in response to mourning for his late wife: #quote[I just feel like forgetting about her for eight hours a day isn't the same thing as healing.] +As with not-mothers and the plasticity of the drive, we will address the psychoanalytic implications here in later posts; but to finish I want to bring our attention to the #emph[imaging of time] at work in just this first episode. + +The fascinating details of failed synchronisation between all the watchfaces we see are enumerated in #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/txxbcm/observation_and_question_regarding_time/")[this Reddit thread]. +Many of the watch hands appear to be stalled, and the crossover from each to the next-- as when Mark Scout switches his wrist watch in preparation for his elevator descent into the workday of innie Mark S-- doesn't match with our experience of the actors on screen. +One of the few things we do know about the severance procedure is that it 'alters perceptual chronologies', and that this messing with a subject's sense of time is thought to + ++ make them more adequate or productive in a certain kind of work (for why else would Lumon go to the necessary lengths to sever some employees) ++ supposes to section off innie memories and experience from outie memories and experience + +So the subject's subjectivity is marked by its sense of time, and Lumon's success (profitability?) hinges in some way on altering their employees' stable sense of it while in the space of the severed floor. + +Mark S's temporal predicament here has been explained by a man whose last name we get by speeding up the saying of his own, Karl Marx (Mar-k-S). +Logically speaking, Marx argues, there is an amount of time that goes missing in the worker's employment by way of a wage, when he advances some portion of his time to the capitalist in exchange for a pay-check one or more weeks later. +I refer the reader interested in the details to #link("https://www.marxists.org/archive/marx/works/1867-c1/ch20.htm")[chapter 20 of #emph[Capital] Vol. I];: but the essential point here is that it is through an obfuscation of the real value of a worker's time that the capitalist manages to produce surplus-value. +The production of this kind of time-distorted surplus-value is the engine of capitalism as a social relation that appears, on the surface, to be equally fair to capitalist and worker alike. +So the project of controlling 'perceptual chronologies' with which Lumon seems to be so concerned is perhaps not as esoteric and inessential as it might at first seem. Perhaps it is an embodiment of the core ingredient of the company's success as a company, of its incorporation as an entity that ought to be sustained even at the expense of its members' happiness, their health, and their livelihoods. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-2.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-2.typ new file mode 100644 index 0000000..1fcc98b --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-2.typ @@ -0,0 +1,122 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 2 + +#import "index.typ": template +#show: template + +#set document(title: [Half Loop - _Severance_ [s1/e2]]) + +#title() + +In #link("./severance-ep-1.typ")[the first episode], we were introduced to the two-sided subject at Lumon. +On the one hand, there is Mark S, the innie, who is screened for the first and major part of the episode. +On the other, Mark Scout, the outie, to whose predicament we are introduced in the concluding scenes. +S1E2 opens with a rewind on how innie Helly R came to be: how Milchick handed her flowers at end of her first day (which we glimpsed in S1E1 when Mark almost ran her over), a glimpse of her confidence gliding into the operating room on a higher floor of the same Lumon complex we saw Mark leave, a stereoscopic view of the implant procedure by which she becomes an android whose existence is #quote[spatially dictated] by Lumon's mysterious machinations. + += Lumon Industries + +#image("img/severance-s1e2-lumon-logo.png") + +Lumon is a corporate pastiche, and not only of technology companies. +Lumon seems to have its hands in surgical hardware (the operating room equipment), digital technology ('Macrodata Refinement'), and medicines and topical salves (as discussed at the dinner party in S1E1 - #quote[What don\'t they make?]). +It is a quintessentially American jack of all trades, a global power in its own right cohered by a family dynasty---the Eagans---recalling the Du Ponts or the Rockefellers. + +The more obvious comparison to make, however, is between #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/1fb28nq/apple_lumon_are_weridly_similar/")[Lumon and Apple], perhaps in part because the show screens on Apple TV Plus. +The style of the computers on the severed floor recalls #link("https://www.historytools.org/docs/computer-history-timeline-personal-computers-computing-internet")[the dawn of the era of personal computing] in the 1970s and 80s, an aesthetic imaginary in which Apple plays an important role. + +Indeed, the aura of Lumon as a futuristic computing corporation from the late 70s is reinforced by the fact that its headquarters are shot at #link("https://ethw.org/Bell_Labs")[Bell Labs] in New Jersey, a building that has now been renovated as a mixed-use office for high-tech startup companies as #link("https://en.wikipedia.org/wiki/Bell_Labs_Holmdel_Complex")[Bell Works]. +Bell Labs is the quasi-mythological source in the contemporary corporate technology culture (Silicon Valley) of the idea that a certain kind of research freedom characterized by open-ended product delivery timelines and serendipitous encounters in open office plans can cultivate ground-breaking technology. +(Mark Zuckerberg #link("https://www.businessinsider.com/mark-zuckerberg-recommends-the-idea-factory-2015-11")[recommended] a book on Bell Labs as one of his #quote[important books] of 2015.) +The irony of this setting, of course, both in _Severance_ and in the technology companies it parodies in the American landscape in 2025, is that the workplace has never been more saturated with surveillance and micro-management. +The overhead shot of Helly R that opened the series is indicative here again, as is the complementary overhead of MDR's desks we get in this episode: there is always something watching from above, it seems, even if what it captures of the actual activity is a flattened and at times misrepresentative image. + +There are also evocations of Microsoft and IBM in Lumon, such as the #link("https://en.wikipedia.org/wiki/Office_Assistant")[Clippy];-like guide on the manual handed to Helly in the episode, or the apparent requirement of suits on the severed floor echoing #link("https://www.reddit.com/r/AskHistorians/comments/7l9ncw/comment/drkzual/")[IBM's infamous strict dress code]. +Lumon is a melange of imaginary pasts, presents, and futures in American innovation. +It is futuristic in the framing of its bio-technological project of perceptual management---and in the #quote[data smuggling] detectors that are installed in the elevators to the severed floor, about which more soon---but retrofitted in its aesthetic, in its management style, and in its outdated repertoire of daily devices. +Recall, for example, Milchick's handheld camcorder, and the tube-activated (vacuum-tube?) camera he uses to snap the official photo +of the new group of refiners. + +#image("img/severance-s1e2-bell-works.png") + +The overhead of Lumon Industries itself depicts a sketchy graph of a brain, one can't help but think. +Its upper floors all operate above board with normally conscious workers, whereas underground there is something sensitive enough happening so as to require extra precautions. +In #link("./severance-ep-1.typ")[S1E1's analysis], we introduced the idea that Lumon's interest in severing workers has to do with the mechanics of capital, in that surplus value can only ever be produced (in Marx's account) through the structural theft of time from its laborers.#footnote[#link("https://fi2.zrc-sazu.si/en/sodelavci/bostjan-nedoh-en")[Boštjan Nedoh] has evocatively called this operation #quote[theft without a thief].] +Lumon's spatial layout suggests that there might also be a psychoanalytic metaphor at stake in severance as an operation, where the happenings that occur in the business brain's basement are essential to what it really is, why it does what it does. + +Though Freud's theory has been popularized as a topographical notion, wherein the unconscious is the submerged part of the mind's iceberg of which we only see the tip, there is good reason to believe that this spatial description misrepresents how the unconscious should be properly understood. +Lacan thus preferred _topological_ descriptors to suggest that, if the unconscious is a 'place' or 'site', it contradicts any over-simplistic understanding of spaces that are distinctly separable. +The relationship between the conscious and the unconscious in a psychoanalytic theory of the subject, I would suggest, is better understood through the figure of a coin with two inseparable sides. +The meaning of any one side ('heads') derives from the meaning of its opposite ('tails'); and it is thus insensible to imagine separating one part from the other without repressing something fundamental about the structure of the subject as a whole. + +Lumon, though, seems to want desperately to keep innies from being in contact with their outies. +Indeed, the very project of severance seems to have something fundamental to do with managing repression effectively, with renovating the worker into a perfectly divided self that cannot complain about the conditions of her labor through the fact of not knowing anything about them. +(When Mark is given a dinner coupon on account of his head injury in S1E1, the real cause of the scar---Helly R's riotous attempt to escape the orientation room---is not revealed to outie Mark.) +The subject in _Severance_ is split and maintained as such. +The 'unconscious' of one's home life should not affect one's 'conscious' ability to perform at work. + +The vice-versa is also true. +Outies cannot suffer the 'unconscious' of their innies, either. +Mark Scout's decision to sever himself seems to be an attempt to repress the devastating effect of his experience of his wife's death for some part of the day, given that he admits he was unable to continue his job as a history teacher due to alcoholism. +At Lumon, however, Mark's alcoholism is brutally functional; as his innie must suffer what (lack of) energy he is given by outie Mark's actions the night before (#quote[I find it helps to focus on the effects of sleep since we don't actually get to experience it]). + +The intellectual impoverishment of Lumon's severed workers is further exposed in this episode as Dylan tries to convey to Helly the substance of what there is to live for as a severed innie: his #quote[embarrassment of wealth] that consists of finger traps, a caricature portrait, and the hope that there might be a #quote[waffle party] on the horizon. +The sad satisfactions that severed workers aspire to reinvigorate the sense of the phrase #quote[wage slavery], an important formulation that in fact has solid footing in Marx's analysis of capital. +For Marx, it is worth comparing the wage worker's predicament to the slave's; for both must labor not for themselves, for their own ends and aspirations, but for an external master that appropriates their efforts. +The important distinction is that, while in _actual_ slavery the slave's enthrallment to the master is explicit and explicitly enforced by means of force, in _wage_ slavery the figure of the master is more diffuse, and hierarchical distinctions are 'justified' in the discursive suggestion of their being fairly and freely established. +The proletariat (wage laborer) is free to choose her own master on the market, selling her labor power to whomever she chooses. +But she is not free to refuse to sell her labor as labor-power; as this #quote[wage slavery] is the generalized means of her reproduction and ability to go on living. +So the proletariat is enslaved to a structure, not a person, and that structure is characterized by the reduction of labor in its multifarious forms to labor-power, a measurement of labor in time that thus becomes exchangeable on the market. +In capitalism, in other words, freedom is structurally reduced to the freedom to choose to whom one sells one labor-power: which is #emph[not] the same thing as freedom tout court. +Thus is the wage laborer unfree in a way that is comparable, though not equivalent, to the slave. + += Death at Lumon +The death culture at Lumon should also be doubly refracted through Marx's analysis of how capital reduces its workers to shadows of themselves on the one hand, and a psychoanalytic understanding of the subject on the other. +When Mark gets emotional about Petey's disappearance during the game of office introductions (which tellingly involves passing around a brignt red ball), Milchick reprimands him with the following explanation: + +#quote(block: true)[ + I think this is a good time to remind ourselves that things like deaths happen outside of here. + Not here. + A life at Lumon is protected from such things. + And I think a great potential response to that from all of you is gratitude. +] + +Severed workers are insulated from death because the very structure of their subjectivity distances the meaning of its concept. +Innies symbolically 'die' when their outies do not come back to work, but this event does not necessarily coincide with their physical death, which as Milchick suggests should only be imagined to take place in the world of their outies. +There is a contradiction here, though, as a physical accident at work would propagate through to an innie's outie. +So Milchick's repression of the notion of death must be recognized as just that: a repression of a certain moment in or dimension of logic (a moment that is too dangerous or frightening to imagine saying out loud), and not as an explication of the necessary consequences of a thorough logic of life. + +Milchick's philosophizing also points to something more sinister in the structure of the severed subject. +The severed worker is protected from death, perhaps, because there is a sense in which he is already #emph[undead]. +Doomed to exist in the artificial enclosure of Lumon's basement and placated only by the pathetic enjoyments of finger traps, company coffee, ideological art, and the odd waffle party, what is there, _really_, to live for at Lumon? +The motto briefly shown on the implant hardware in Helly's operating room scene at the episode's opening has a morbid resonance here: #quote[Don't live to work. Work to live.] + +There is a stronger psychoanalytic sense in which we might make sense of Milchick's discourse on death that is worth mentioning here, too. +Lacan articulates a distinction between two kinds of death in his theory of the subject, a first death that is #strong[biological] and a second death that is #strong[symbolic]. +I will explicate this theory later in S1, when Milchick's foreshadowing of death's importance in the show bubbles clearly to the surface in a later episode. + += Capturing and controlling the symbolic +Let's talk about the #quote[symbol detectors] in the elevators, which are introduced in this episode. +These are the real basis of how Lumon separates innies from outies, as they supposedly ensure that no notes, no language, is passed between the two kinds of self. +In S1E1, we saw outie Mark put the tissue he had been crying into in his car in his pocket; and we then saw innie Mark confidently strolling out of the elevator on the severed floor, quizzically discovering the tissue in his pocket, and tossing it into a bin on his walk down the hall to MDR. +So the suggestion has already been planted in our (the viewers') mind that it is #emph[possible] to traffic objects across the boundary. +The other clear evidence of this is offered here in S1E2, where Irving similarly, quizzically, observes the black sooty substance underneath his fingernails during the distraction of the melon party. + +Yet Helly's note to herself triggers the alarms, resulting in the elevator doors refusing to close and a screen washed out with red alert. +So they do seem to have some power to detect 'symbols'. +But what marks the boundary between a symbol and a non-symbol for this technology? +It is not only explicit language in the form of written or spoken words that make meaning for us as human animals. +We are affected by a frightening range of other things; colors, tactile memories, qualities of our past selves that seep into our present (such as too much alcohol drunk the night before). +So it is hard to imagine, knowing the complexities of our selves as we all do, that Lumon could really effectively police the boundary between innies and outies, even with its back-to-the-future technological prowess. + +Indeed, the audio recording that innie/outie Petey shows outie Mark in his hideout at the greenhouse reveals the insecurity of symbol detection at Lumon. +In order to get a recording of what he was subjected to in the Break Room, he must have been able to get that retro handheld device back up into the 'real' world. +So either the elevators weren't able to pick it up, or there is some other way for innies to move between the supposedly demarcated spaces. +Either way, the symbol policing at the innie/outie border seems to have some shortcomings. + +A brief note on Petey's dishevelled greenhouse to conclude, as this episode is where we are first introduced to much of the geography that will become important in the series: the break room, wellness, MDR, optics and design, Mark's basement, the company restaurant (where Mark has his insufferably awkward date), the elevator, the MDR kitchen, the operating room, the Lumon foyer. +Petey's greenhouse, like many of the spaces in #emph[Severance], is a graph that both embodies and reflects a psycho-social moment of the show. +Green like Macrodata Refinement, but much less put-together, the greenhouse reveals the underside of Lumon's apparent glaem, the unconscious damage that its project of perfection wreaks on its workers psychologically and physically. +Petey shows us that the worker, like so many words and things in the show, is not simply what it seems, but consists also of an excess signification that inevitably creeps into its conspicuous comportment. +Mark is a depressed drunkard on the outside, and Irving (it seems) has his fingers in some hellish kind of black pie, a color that takes over his desk as he dozes off when he lets the distinction between his waking and unconscious self slip, we might say, when the reality of sleep threatens the security of being awake. +There is, as the imagery in the poster of the 'Whole Mind Collective' that motivates Mark to bunk off and follow up on Petey's enigmatic red letter suggests, a real revolution of sorts brewing beneath the surface of a fantasy of symbolic control. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-3.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-3.typ new file mode 100644 index 0000000..8c1f032 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-3.typ @@ -0,0 +1,197 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 3 with images + +#import "index.typ": template +#show: template + +#set document(title: [In Perpetuity - #emph[Severance] [s1/e3]]) + +#title() + +#image("img/severance-s1e3-shot1.png") + += We need to talk about Ms. Cobel +As we noted in #link("./severance-ep-1.typ")[analysis of S1E1], she typically storms the screen with an icy blue, a temper (the significance of this word we shall unpack shortly) that seeks to quell the fiery red that flickers in and out of the consciousness of workers on the severed floor. +The ominous ending to that first episode intimated that, while her wintery business has its office underground, it also warrants her prying into Mark's outie's personal life in Baird Creek's subsidized Lumon housing. +Indeed, it seems that Miss Cobel lives in Mark's housing complex, too. +From the state of her fridge, though, which we see in the foreground of a shot that implies surreptitious surveillance at work in her intimate space-- a sense that has already been produced in Mark's home with objects littered in the frame's foreground-- it doesn't appear that she spends very much time making a home there. +(Not too unlike Mark, perhaps.) + +Ms. Cobel is a kaleidoscopic vector of strange femininity in the show. +She is at once old widow next door, a girl-boss superior on the severed floor, and a little girl prone to tantrums. +As Mrs. Selvig, the hare-brained widow next door, she offers Mark unwanted company and cruddy cookies. +Yet we know by now that this is apparanetly a ruse, a senile disguise through which the conniving Harmony Cobel can keep an eye on her employee, Mark, beyond the bounds of his time at work. +At Baird Creek, she is a middle-aged executive in the clothing of an older and less cognitively composed character. + +But even if Ms. Cobel is the 'real' Mrs. Selvig, there is still something anile about her character. +She can be both comandeering and childish, as we see in her encounter with (innie) Mark S when he arrives unannounced at her office to request a kind of permission to take Hellie to the perpetuity wing in S1E3. +Commandering, because she accosts Mark with bureaucratic demands in her role as his boss (#quote[And have you filled +out a common-reservation slip?]). +Childish, because she literally throws a mug at him out of a petty frustration that is unbefitting of a mature manager. +Cobel rationalizes her childish temper as follows: + +#quote(block: true)[ + What I just did was something I knew that you could handle and grow from. + It was very painful for me. + I hope that you'll let it help you. +] + +This outburst locates something undecided within Ms. Cobel, a moment in relation to Mark where she lets her personal anger supersede her role as his manager. +This mug-throwing episode demonstrates that Cobel, too, is capable of breaking character as head of the severed floor and allowing some other aspect of her self to seep in, despite the pretense of a calm composure. +The thrown-mug, in other words, is the wish fulfillment heralded by Cobel's stunningly funny, inappropriate remark to Hellie during her orientation in S1E1; #quote[I've wanted to pummel Mark myself, but I am his employer.] +Even Cobel, who is supposed to be more in charge of herself than the MDR employees who are her inferiors-- her breaking into Mark's house while he isn't there implies is that she is unsevered, and thus more 'responsible'-- harbours desires that exceed and contradict the prescribed role she is supposed to play. + +The image of Cobel above confirms her as childish in some respects. +Notice that here, at 'home', she wears her hair in pigtails rather than loosely around her shoulders. +But it also paints her as a scopophilic and overbearing #emph[mother]. +Whatever she is doing creating excuses to talk to Mark's outie as Mrs. Selvig, it becomes clear in this episode that there is a convoluted kind of care at stake in her creepy and overcurious work. +Peering at him as he wanders up from the basement (Cobel doesn't seem to know that Petey is also down there at this point, though her break-in later in the episode suggests that she suspects something is awry), she murmurs to herself, #quote[Oh, Mark. Are you all right?] + +This is a strange exhibition of affection, coming from the same woman who will throw a mug at Markfor his failure to #quote[get MDR to its numbers] as department chief, who knowingly subjects him to the break room-- which we observe on screen for the first time later in the episode-- and who steals the book left by his brother-in-law as a gift at his doorstep. +Despite these mistreatments, Cobel does still seem to hold some perverted penchant for and attachment to Mark. +As HaxDogma notes in #link("https://www.youtube.com/watch?v=JAhhVnevSm4")[his review of this episode], it is hard to see Mark's promotion to department chief after Petey disappears as anything other than a nepotistic appointment, given that Irving is clearly the more experienced refiner in a number of respects (orientation procedure, group photo protocol, number of years spent on the severed floor, to name a few). +Cobel's overinquisitive manner on display in this episode is perhap best described as motherly, even as she is certainly not a paradigmatically #emph[good] mother. + +There is also something undoubtedly sexual about Cobel's relationship to Mark. +Her lingering at the door in S1E2 waiting to be invited in, her awkward and suggestive mention of her late husband's building an apartment in the back of their abode in heaven #quote[in case I found a new man before I got there], her creating an excuse to talk to him by pretending to de-ice her stoop; and, naturally, her peeping at him through the window. +She is either a stalker by-the-book, or (more charitably) a lonely woman who is searching for some missing satisfaction. +Most likely, she is an inextricable concoction of the two. +Cobel wants to have Mark's cake and eat it too; to be at once his mother, his corporate superior, and (we can't help but suspect) his lover. +Like many put in positions of power, she has trouble setting her more inapproriate desires aside so as to simply 'do her job'. + += Primal father figures +Cobel's mother energy is arguably muted and mixed up in her #link("https://en.wikipedia.org/wiki/Sphinx#Riddle_of_the_Sphinx")[Sphinxesque] triplicity. +But the father energy on display in this episode is, by contrast, loudly and proudly pronounced in at least three different figures: Petey, Irving, and, of course, Kier Eagan. +#footnote[ + There is foreshadowing, too, of a fourth father figure in Rickon, Mark's brother-in-law. + While reading his confiscated book, Milchick quietly remarks to himself a thought that will become an important refrain for many other characters with respect to Rickon later in the season: #quote[This is… Jesus.] +] +Before tackling these fathers one by one, it is instructive to straightforwardly and schematically lay out the #strong[Oedipus complex], an 'absolute fiction' that nonetheless, Freud claims, depicts something foundational about the graph of the speaking subject, the graph in which we took interest in #link("./severance-ep-1.typ")[our analysis of S1E1]. + +The Oedipus complex is so-named because it takes its architecture from the figure of Oedipus as he appears in the ancient Greek playwright #link("https://www.cliffsnotes.com/literature/o/the-oedipus-trilogy/about-the-oedipus-trilogy")[Sophocles' trilogy], which consists of the plays #emph[Oedipus Rex], #emph[Oedipus at Colonus], and #emph[Antigone]. +(Oedipus' tragic tale is drawn from a mythology that predates these plays, but the story is nonetheless usually traced to its Sophoclean production.) +Oedipus is well-known to students of psychoanalysis because of Freud's making him into a #link("https://nosubject.com/Oedipus_complex")[complex], which is generally (mis)understood as 'every person wants to kill their father and fuck their mother'. +Famously, Oedipus killed his father-- at a crossroads, thinking he was simply a threatening stranger at the time-- and married his mother-- not understanding that relation in the moment of the act, either. + +Jacques Lacan rendered the Oedipus complex more philosophically significant than this overblown and crude Freudian telling. +For Lacan, the Oedipus complex designates an abstract account of how desire is produced by the speaking subject in relation to the formative figures with which it is in relation. +As he notes in one of his 1938 text, #emph[The Family Complexes]: + +#quote(block: true)[ +our criticism since Freud presents this psychological entity [the Oedipus complex] as #emph[the specific form of the human family] and subordinates all social variations of the family to it. @lacanFamilyComplexesFormation2002[p.35] +] + +The Oedipus complex is not so much a diagnosis of a particular perversion that is presumed universal, in the sense that everyone #emph[consciously] suffers by repressing these secret dual desires to kill (my father) and to fuck (my mother). +It is rather an important part of how he architects a philosophy of the subject's relation to itself (and others) by way of a #quote[triangular conflict] @lacanFamilyComplexesFormation2002[p.41] between three figures: one's self, the Mother, and the Father. + +The Mother is the subject's first known object that is seen as separable from one's sense of self. +We can imagine this through the process of weaning, of a mother teaching her baby that sustenance ought to be sought in solid foods rather than directly from her teat. +Originally, a baby does not have a firm enough sense of itself to recognize that the Mother's teat is separated from its own body. +When it wants nourishment, it cries, and a breast brimming with milk appears (assuming a good mother, here). +The breast seems almost part and parcel of the baby, from its perspective, as what reason does it have to think otherwise? +(We are assuming here that the separation between a baby's sense of its own body and the world is not ingrained at birth, but rather learned, acculturated.) +It is only when the baby's crying stops precipitating a breast that it should start to doubt this part of itself, to think that perhaps my Mother's breast is not part of #emph[me] as subject but rather its own kind of thing, a separate object. +Thus the Mother is, in this developmental sense, the subject's first #emph[proper] object. +The Mother (and her breast), the baby subject thinks, is both mine and not mine, as though there is some #emph[relation] that my Mother has to me, she is not (quite) the same as me. + +The Father, on the other hand, incorporates (into) the baby subject's sense of self differently. +It is not considered, as the Mother is, a part of the subject that was at some point taken away, but rather represents the source of that action of taking away. +If the Mother #emph[ought] (in the terms of the baby subject's nascent ethics) to be a part of me, the Father is the force and figure responsible for taking her away. +This stature of the Father is better understood, perhaps, with reference to the myth of the #strong[Primal Father], which Lacan reinterprets from its presentation in Freud as originally depicted in the fourth and final chapter of #emph[Totem and Taboo] @freudTotemTabooResemblances1919. +Like the Oedipus complex, the myth of the Primal Father is a narrativization that helps to understand the structure of the subject. +Suppose a primal horde, Freud offers, at the helm of which exists a Primal Father who monopolizes all women. +All women in the horde, in other words, are sexually subject to this single male; no other male gets to enjoy anything of them. +A band of brothers, resentful of the Father's monopoly on enjoyment, conspire to escape the ban on sexual enjoyment through a plot to murder him. +#footnote[ + There has been much written on Freud's mythos of the Primal Father. + For a relatively recent use of the concept that serves as a reasonable introduction to Lacan's reading of #emph[Totem and Taboo], see @mcgowanDistributionEnjoyment2021. +] +They do so through what could be called an original jealousy, a feeling that the Father is enjoying in a way that is prohibited (by virtue of the Father's taboo) for each of them. + +Freud offers this as an #quote[historic explanation… [of] the origin of incest] @freudTotemTabooResemblances1919[p.207], as the Primal Father's taboo on enjoyment is what, Freud suggests, drives exogamy, wherein each of the band of brothers leaves that original tribe to start their own in which they can (finally) enjoy the women for themselves. +That this is an historic explanation does not mean that Freud believes that it represents an actual state of affairs in some distant past. +Indeed, he states the opposite, that #quote[primal state of society has nowhere been observed.] @freudTotemTabooResemblances1919[p.233] +The parable of the Primal Father is historic rather in the sense that narrates to us an important aspect of the structure of the subject, much like Oedipus' tragedy. + += Daddy issues at work + +Okay: we now return from this Freudian digression to the stuff of #emph[Severance]. +What bearing do the Oedipus complex and the myth of the Primal Father have on the structure of the subject on display in the show? +Let's go now to the scene in S1E3 at the crossroads, where MDR runs into two employees in Optics and Design (O&D). + +#image("img/severance-s1e3-shot2.png") + +The composition of this shot puts the reflective axis down the center, and the encounter is suggestively Oedipean in its structure (at a crossroads, unknowing of the Other at play). +Note that Irving is compositionally mirrored by Burt, played by Christopher Walken, and we will explore this suggestive symmetry in detail in later episodes. +The two departments (MDR and O&D) know #emph[of] each other, we surmise from the dialogue that follows. +But Irving isn't supposed to know Burt by name, as he accidentally happened upon him in S1E2 on the way to a Wellness session. +(Burt was coming #emph[from] his Wellness session.) + +While Irving greets Burt on the back of this previous encounter with gentle and flirtatious warmth, Dylan's hostility towards O&D is clear. +In place of the camaraderie that one might have hoped for between the two factions given their shared plight as severed workers, there appears to be an enmity built on a mythology (what Irving calls an #quote[absolute fiction]) of otherness: + +#quote(block: true)[ + Kier sorted the departments by virtue. + Macrodats are clever and true, while O&D's more cruelty-centered…. + O&D tried a violent coup on the others decades ago, and that's why they reduced them down to two. + And that's why they keep us all so far apart now. +] + +Kier is evidently the Primal Father of the severed floor, responsible for instituting the symbolic system of rules, regulations, and affects in the various 'bands of brothers' which reside there. +The tour of the perfect replica of Kier's house later in the episode reinforces his architectural status as Primal Father. +Irving chides Mark for his lack of reverence in deigning to turn the tour of the Perpetuity Wing into Eagan Bingo, and is aghast when he almost happens to #quote[bed sit] on the facsimile in his duplicate chambers. +(Thou shall not lie in Kier Eagan's bed.) +Kier and the lineage of Eagans more generally constitute the #link("https://nosubject.com/Law_of_the_father")[law of the father], the signifier of authority that keeps the severed floor's social order intact, the symbolic source from which both rules and the forbidden temptations of their being broken, taboos, sprout. Irving fosters this authority during the tour, standing in for the absent caregivers, existential (Kier, the Eagans) and material (Cobel and Milchick as superintendents who seem to be letting the kids take care of themselves for a short period). + +Another paternal authority whose absence has haunted and structured Mark since the show's opening is Petey, the man whose shoes he stepped into as MDR's department chief. +As per his exchange to Cobel in the mug-throwing scene, Mark lionizes Petey as a tone-setter, often acting through an ethics refracted by the subordinate conjunctive, 'if Petey were here', or the preface 'Petey used to say'. +Mark's innie is steered more by an imagined sense of what Petey would do, rather than what Kier would. + +Thus while it is Cobel who is explicitly in charge, the spectral presence of these father figures-- Kier, Petey, Irving-- correlatively structures the subject on the severed floor. +There is, in other words, an Oedipal triangular conflict at work in relation the ethical imperative of a severed worker. +The four members of MDR, as orientations to the structure of this subject, suffer different relationships to the positions of Mother and Father. +Mark S is a momma's boy, sired more by Petey's radical rejection of company policy than by Kier. +Dylan, though impertinent to the minutiae in the structure of Law at times, is ultimately his Father's son, acquiring satisfaction by accumulating accolades, and apparently driven by the impending idea of another finger trap or a waffle party. +Irving seems at this point the most mature of the children, looking reverentailly to Kier. +Yet recall that he has been chided by Milchick already for falling asleep on the job, so not all is perfect in paradise. +Hellie has no time for Cobel's authority, yet we will see in due course that her relationship with a Father is a deep lineament in her personality, too. + += Taming tempers +The count of four in the members of MDR mirrors the exact amount of tempers that we learn about from Kier Eagan's wax simulacrum speaking during the tour of the Perpetuity Wing. +These tempers are crucial as coordinates of the Eaganic attempt to coherently quantify the subject, and Kier's pronouncement is deeply significant for our investigation of the subject's distorted structure on the severed floor: + +#quote(block: true)[ + I know that death is near upon me, because people have begun to ask what I see as my life's great achievement. + They wish to know how they should remember me as I rot. + In my life, I have identified four components, which I call tempers, from which are derived every human soul. + #strong[Woe. Frolic. Dread. Malice]. + Each man's character is defined by the precise ratio that resides in him. + I walked into the cave of my own mind, and there I tamed them. + Should you tame the tempers as I did mine, then the world shall become but your appendage. + It is this great and consecrated power that I hope to pass on to all of you, my children. +] + +If there was any doubt that Kier Eagan embodies the Freudian Primal Father, the foundational component of absolute fiction on which the edifice of Law (the rules and taboos by which a subject is bound to abide) is constructed, the quotation above should put it to bed. +Kier's 'philosophy' seeks to conquer death by quantifying life, sorting its myriadic nature into a #quote[precise ratio] of character that can be counted (completely, it seems) in four distinct tempers. +Indeed, we saw the pictorial representation of this taming in s1e2, in the scene where Irving meets Burt: + +#image("img/severance-s1e3-tamingtempers.png") + +In the post-Platonic cave of his own mind, Kier is the master of his passions. +He admits no unconscious contours that sneak up on him unbeknowst in Freudian slips of the tongue or unwanted symptoms. +Indeed, the Eaganesque fantasy of the subject is one in which the necessary excess of language that psychoanalysis discovered does not exist. +Words are detected (via sensors in the elevator, say), controlled, managed. +Any psychoanalytic excess is, in Kier's project of a precisely rationalized subject, beaten out of language. +Excess meaning is 'tamed' as if it were a wild animal by a clear-headed, upstanding, divinely radiant visonary. +(As we will see, the position of primal power that Kier occupies here is sexually overbearing, too, as we might suspect from the Freudian analogy.) + +This episode ends with two scenes depicting the dark and bloody underside of Kier's waxen vision of the precisely quantified human subject. +The first is Helly's harrowing experience in the break room, a space where the unruly distance between words as they are uttered and the meaning they convey is thought to be stamped out, suffocated by the drudgery of debilitating repetition. +A subject will not exceed its authorized symbolization, the break room seems to want to claim. +The worker's unconscious will be tamed and ultimately made beholden to a regime of conscious rationality. +The second, and the closing scene of the epsiode, is Petey's psychotic demise at the convenience store, where he yells at wit's end: #quote[I need tokens so I can eat!] +Ravaged by the failure of his complete quantification inside Lumon, Petey seems no longer to have a firm footing in either his innie's or outie's reality. +Mark looks on from a distance as he collapses outside the store, escorted by police, attempting (it seems) to account for his disintegration. + +#bibliography("./references.bib", style: "chicago-author-date") diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/writing-in-typst.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/writing-in-typst.typ new file mode 100644 index 0000000..1861235 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/writing-in-typst.typ @@ -0,0 +1,132 @@ +// @rheo:test +// @rheo:formats html +// @rheo:description Blog post with HTML video elements and custom functions + +#let video(path, width: "auto", height: "auto", controls: "true", autoplay: "false", loop: "false") = { + html.elem("video", attrs: ( + src: path, + width: width, + height: height, + controls: controls, + autoplay: autoplay, + loop: loop, + style: "max-width: 100%", + )) +} + += Writing in Typst | Hacking on Neovim with Claude +== What is a 'good' writing system? +I have been #link("https://www.ohrg.org/devonthink-part-i")[incrementally] #link("https://www.ohrg.org/devonthink-part-ii")[hacking] #link("https://www.ohrg.org/devonthink-part-iii")[on] my writing environment for some time now, since at least 2013 when I started seriously using computers in undergrad. +A couple of years ago, I migrated to Orgmode as the best markup syntax for my needs, and #link("https://www.ohrg.org/writing-setup")[wrote aa post about how Emacs and Orgmode serviced my writing needs]. + +Here's a summary of that post and the core tenets of what I consider an acceptable writing environment, parsed out over the five or so years I've been experimenting with one through grad school: + ++ *Flexible, powerful and distraction-free*. + In short, this means that the environment needs to be an extension to a modal editor in the terminal. + I started using a #link("https://carlosbecker.com/posts/ed/")[modal text editor] around 2018, and use a range of ergonomic keyboards in #link("https://www.ohrg.org/cycling-typing")[funky ways] that make using a mouse undesirable in most cases. + (The web browser is the one environment where I still get some mileage out of a mouse. + I do a lot with keyboard shorcuts via #link("https://vimium.github.io/")[Vimium], but there are still some contexts where it's just quicker or more comfortable to use a mouse.) + One of the main reasons that I settled on Orgmode rather than, say, Markdown at the time was because of its #link("https://orgmode.org/manual/Citations.html")[more standardized bibliographic management]. + ++ *Non-proprietary and sane markup format*. + Microsoft Word documents and Google Docs are great for a lot of things, but I refuse to rely on either of them as a primary format for all of the writing I do, as their formats are to hard to parse (to write custom software for) and bound to Microsoft's and Google's ecosystems respectively. + The ability to run Unix-style comands on a simple markup format from a terminal to search and replace, for example, is an essential. + Writing documents in a plain-text markup language also gives me the safety of knowing that, if it really came down to it, I could write my own parser and compilers. + My writing archive shouldn't strictly rely on some company's infrastructure to host, search, or otherwise make use of the thought it contains. + Using such a format also means that cross-platform editing is made simpler and possible. + (I run linux mostly, but still regrettably use Android as my phone's operating system.) + ++ *Multi-format export*. + #link("https://willcrichton.net/notes/portable-epubs/")[Most of the world's documents are still PDF]. + There's no getting away from needing to export writing as PDF in many cases-- for e-readers like #link("https://www.ohrg.org/using-two-remarkables")[the reMarkable that I use], or for submission to conferences. + But we increasingly read writing on a web page of some sort, and so I also need a workflow to export fully functional documents to HTML and CSS, too. + Other formats that are interesting if not essential include some kind of presentation file (PowerPoint, or better: just a website that has slideshow-like interactions), Markdown for rich formatting to copy somewhere, and plain text. + +I have up until very recently used Orgmode as my markup language of choice, exported them to PDF with exported them to PDF with #link("https://www.latex-project.org/")[latex], and exported them to HTML with #link("https://pandoc.org/")[pandoc]. +But I am very attached to the Neovim ecosystem for my code editing and writing, and so it was clunky to open up an Emacs installation (that I barely understood) exclusively to edit Orgmode. +So I switched to editing Orgmode in Neovim along with everything else, #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[using plugins] and #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[custom functions] to get towards the writing experience that I wanted. + +This has actually worked surprisingly well, but it has some sharp edges. +One of the more significant ones is that any time I want to produce anything more complicated than basic, formatted text with citations and footnotes-- for all of which pandoc transformations produce reasonable output in both HTML and PDF-- I need to start embedding LaTeX into Orgmode, and deal with the LaTeX toolchain / dependency management in order to compile a PDF. +Similarly, if I want to produce an interactive HTML document, I need to embed the source code directly in Orgmode and ensure that the export process handles dependencies and the like appropriately. + +Some of this is unavoidable. +If I want to run custom Javascript in a website that is well beyond the expressive capacities of a markup language, at some point I just want to be able to write Javascript. +But what I found frustrating about my Orgmode / LaTeX / HTML workflow is that there wasn't any reasonable way to work towards extending the markup language in _some_ ways, unless I was willing to start developing my own bespoke flavor of Orgmode plus plus. +I also don't particularly like wrestling with the LaTeX ecosystem, because-- and this is hardly controversial to say-- #link("https://tex.stackexchange.com/questions/222500/why-is-latex-so-complicated")[LaTeX has a lot of bloat]. +What I wanted was a more _extensible_ system which had saner defaults. + +== Enter: Typst + +A few months ago, I started seriously considering #link("https://typst.app/")[typst] as a potential replacement for LaTeX. +At the very least, I thought, it would be more fun to wrestle with a modern ecosystem when struggling to produce some custom table or figure in my output PDF, as typst has a #link("https://typst.app/docs/reference/layout/")[layout system] that uses terms that are a lot more intuitive to me than the black magic of laying out LaTeX documents. + +It just so happened, however, that I started to follow typst development more closely at a time when the final touches to the #link("https://github.com/typst/typst/issues/5512")[basic foundations of HTML export], such as footnotes and bibliography, were just about to be added to the upstream. +So I made #link("https://github.com/typst/typst/pulls?q=is%3Apr+author%3Abreezykermo+is%3Aclosed")[a few contributions] to spirit it along, and started more serious experimentation using typst as a unified way to produce _both_ PDF and HTML in my writing environment. +Pandoc #link("https://pandoc.org/MANUAL.html#typst")[can convert to and from typst], so I originally intended to keep writing documents in Orgmode and then transiently convert them to typst in order to produce PDF and HTML both. +But I quickly found that the typst syntax natively accommodates all of the features that I make use of regularly in Orgmode such as citations, footnotes, headings, links and text decoration-- and then some. + +So why not write my blogs, papers, and documents directly in typst? +I considered the critical features of my Neovim / Orgmode writing environment that I didn't want to abandon: + ++ *Shortcuts for markup*. + The #link("https://github.com/nvim-orgmode/orgmode")[nvim-orgmode plugin] makes writing Orgmode in Neovim pleasurable, providing shortcuts to insert a link and basic text decoration while composing. ++ *Citation and link picking*. + Though I've gone without it for a few months for reasons that are immaterial here, I used to have a shortcut to bring up a fuzzy finder for all of my bibliography entries to easily insert a citation. + The same fuzzy finder would make it easy to link to local files (in a website, for example, to link to other posts). ++ *Document folding*. + The ability to fold away all of the text beneath a heading is very useful when navigating larger documents, as it helps me to compartmentalize writing tasks and organize longer documents such as a dissertation chapter. ++ *Export shortcuts*. + I have customized my Neovim editor so that I can easily export the active Orgmode document (through the pandoc and LaTeX processes described above). + Personally, I don't feel that I need a real-time live preview of the document as I type, as I generally just want to check that it looks reasonable at certain junctures in the writing process, rather than continuously. + +The one other features of Orgmode that I have come to rely on heavily is its #link("https://orgmode.org/manual/TODO-Basics.html")[TODO functionality]. +I typically only use this in notes related to projects or tasks more generally, however, and not in documents that are intended for publication such as a paper or blog post. + +== Enter: Claude Code +At this point in the past of a new writing technology's prospecting, I would go searching for a Neovim plugin for typst and hope that it provides features that satisfy a majority of these requirements. +I've spent a fair bit of time #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua")[tinkering with my init.lua], the entrypoint for customizing Neovim, but I've never had the time nor interest to sit down and write a plugin from scratch. + +LLMs, of course, are at time of writing taking the coding world by storm. +I have started moderately relying on #link("https://github.com/anthropics/claude-code")[Claude Code] when writing some-- though certainly not all-- kinds of code. +As is well-known by now, Claude is especially good at scaffolding hacky scripts or modules from scratch, when no large codebase or domain-specific knowledge needs to be kept in context. +A Neovim plugin, I realized this morning, is a pretty ideal domain for LLM-assisted coding. +The 'codebase' is often just a single configuration file, and the domain-specific knowledge is the Neovim editor itself, a well-documented and expansively customized software for which there are many examples on Reddit.#footnote[It's impossible to mention LLM coding at this time without adding some sort of disclaimer that, no, I don't think AGI is around the corner, and yes, I do expect both programming languages and language writ large to remain 'a thing' in the foreseeable future. LLMs are an incredibly powerful tool to write and analyze code and text, but the purpose of code and text-- as a medium of symbolic communication amongst social beings-- has not been rendered valueless since ChatGPT became publically available. If anything, the value of adeptly and adroitly handling written language has taken deeper root. For my preliminary thoughts on why we are so keen to imagine that computers will supplant the usefulness of the human, I refer the reader to #link("https://caiml.org/dighum/announcements/digital-humanism-salon-capital-and-the-computer-by-lachlan-kermode-2024-06-24/")[this talk I gave in 2024].] + +So I fired up Claude Code earlier this afternoon, and-- fast-forward an hour or two-- I have a fully functional writing environment for Typst that essentially has feature-parity with my Orgmode environment. +Moreover, my #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim")[Neovim config] is now much more comprehensibly modularized; and I have a tried-and-tested method for extending it without needing to spend days learning the ins-and-outs of Neovim's API; and #link("https://github.com/breezykermo/nixos/commit/67cdbbae0dd77db766289b7f6eb278091ab937dd")[some bugbears in my NixOS config were eliminated] while I was at it. +(If that last bit means nothing to you, count yourself lucky!) + +== My new writing environment +I use #link("https://tree-sitter.github.io/tree-sitter/")[treesitter] for syntax highlighting, and Typst already looks pretty good with it. +I get function completion #link("https://github.com/breezykermo/nixos/blob/main/home-manager/server/neovim/lua/plugins/lsp.lua#L17-L24")[by integrating an LSP for the format], for which I'm using #link("https://github.com/Myriad-Dreamin/tinymist")[tinymist]. + +As I noted above, I haven't had dynamic link or citation insertion for some time. +It was one of the features that got lost in my move from writing Orgmode in Emacs to writing it in Neovim. +I use #link("https://github.com/nvim-telescope/telescope.nvim")[telescope.nvim] for general search and file-picking when coding in Neovim, and I figured that I could use a customized pop-up to dynamically pick available citations from the relevant #link("https://www.overleaf.com/learn/latex/Bibliography_management_with_bibtex")[BibTeX] file, too. +After a few minutes of #link("https://simonwillison.net/2025/Oct/7/vibe-engineering/")[vibe-engineering], I have the following: + +#video("../img/typst-links-citations-demo.mp4") + +When I am writing in Typst, and I want to bring in a reference, I can open a panel. +Note that the search is full-text, not just using the reference ID. +I also have a shortcut to specify which bib file to use through the `#bibliography` function in Typst. +I can insert links in the same way as citations, both references files relative to the current one (blog posts on the same site), and external links. +Both the citation and link insertion work either by highlighting text and annotating it, or to insert new links/citations. +I also have a similar shortcut to add footnotes. + +This is pretty functional now for generic writing! + +== Future work +Typst isn't ideal for producing fully-featured websites currently, as HTML export is experimental. +Even when it becomes better supported, the project is-- understandably, given its priority supporting PDF-- taking a #link("https://github.com/typst/typst/issues/5512")[relatively conservative approach] to HTML generation. +Anything that doesn't have a robust analog in a PDF document, such as videos and hover panels, will have to be 'embedded' in Typst with HTML/CSS/JS, rather than being written in Typst syntax. +The current experience isn't much worse than Orgmode with Pandoc, though, and the Typst roadmap promises that it will become much better in the relatively short-term future. + +There is a longstanding issue that I've had with links in Orgmode that I haven't yet tackled with Typst. +When I'm writing, I like hyperlinked text to appear as it will in the final document, i.e. without the underlying URL on display. +When editing any particular line, though, it's better that all of the links are 'expanded' to their full source syntax (`#link("...")[...]`) so that its feasible to edit the markup without requiring any fancy shortcuts. +The effective shortening of lines that occurs when hiding these URLS results in different Neovim line-wrapping requirements, with which the Orgmode plugin I have been using does a bad job, giving ugly linebreaks in documents with long links. +This link presentation will likely be the next feature I add to my Neovim Typst plugin. + +I'll add to the capabilities in #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim/lua/typst")[my Neovim config files], and might eventually release a separate plugin if the features become significant/mature enough. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/adobe-digital-edition.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/adobe-digital-edition.jpg new file mode 100644 index 0000000..3965474 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/adobe-digital-edition.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/after-resize.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/after-resize.jpg new file mode 100644 index 0000000..ef30c8b Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/after-resize.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/apple-books.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/apple-books.jpg new file mode 100644 index 0000000..2d38574 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/apple-books.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/before-resize.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/before-resize.jpg new file mode 100644 index 0000000..5ee7da3 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/before-resize.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/bene.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/bene.png new file mode 100644 index 0000000..4b7c9ed Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/bene.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/history-of-writing-kindle.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/history-of-writing-kindle.jpg new file mode 100644 index 0000000..fbf2458 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/history-of-writing-kindle.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/history-of-writing-pdf.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/history-of-writing-pdf.jpg new file mode 100644 index 0000000..cbb24be Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/history-of-writing-pdf.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/kindle.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/kindle.jpg new file mode 100644 index 0000000..e01c1f0 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/kindle.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/tags.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/tags.jpg new file mode 100644 index 0000000..7640a41 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/img/tags.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/portable_epubs.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/portable_epubs.typ new file mode 100644 index 0000000..9f2ec39 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_post/portable_epubs.typ @@ -0,0 +1,476 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Long-form article with custom HTML elements and code examples + +#let html-element(body, name: "div", attrs: (:)) = context { + if target() == "html" or target() == "epub" { + html.elem(name, attrs: attrs, body) + } else { + block(body) + [#attrs] + } +} + +#let custom-element(name, attrs: (:)) = html-element.with(name: name).with(attrs: attrs) + +#let header = custom-element("header") +#let authors = custom-element("doc-authors") +#let author = custom-element("doc-author") +#let author-name = custom-element("doc-author-name") +#let author-affiliation = custom-element("doc-author-affiliation") +#let publication-date = custom-element("doc-publication-date") +#let abstract = custom-element("doc-abstract") +#let section = custom-element("section") +#let definition = custom-element("dfn-container") + +#let defined-word(id: "", body) = custom-element("dfn")(attrs: (id: id), body) + +#let callout(body) = custom-element("div")(attrs: (class: "callout"), body) +#let def-link(id, body) = custom-element("a")(attrs: (href: "#" + id, data-target: "dfn"), body) +#let code-description = custom-element("code-description") +#let code-step = custom-element("code-step") +#let pre = custom-element("pre") +#let span = custom-element("span") +#let code-def(id, body) = span(attrs: (id: id), body) +#let code-description(body) = { + let verbatimize(items, indent) = { + items + .filter(child => child.func() == enum.item) + .map(item => { + if item.body.has("children") { + let children = item.body.children + let item-idx = children.position(child => child.func() == enum.item) + if item-idx != none { + let prefix = children.slice(0, item-idx) + (span(indent), prefix.join(), "\n", verbatimize(children.slice(item-idx), indent + " ")).join() + } else { + (span(indent), children.join()).join() + } + } else { + (span(indent), item.body).join() + } + }) + .join("\n") + } + pre(verbatimize(body.children, "")) +} +#let code-steps(body) = { + body + .children + .map(child => { + if child.has("body") { + code-step(child.body) + } else { + child + } + }) + .join() +} + +#set document(title: "Portable EPUBs") + +#header[ + #title() + #authors[ + #author[ + #author-name[Will Crichton] + #author-affiliation[Brown University] + ] + ] + #publication-date[January 25, 2024] + #abstract[ + Despite decades of advances in document rendering technology, most of the world's documents are stuck in the 1990s due to the limitations of PDF. + Yet, modern document formats like HTML have yet to provide a competitive alternative to PDF. This post explores what prevents HTML documents from being portable, and I propose a way forward based on the EPUB format. To demonstrate my ideas, this post is presented using a prototype EPUB reading system. + ] +] + += The Good and Bad of PDF + +PDF is the de facto file format for reading and sharing digital documents like papers, textbooks, and flyers. People use the PDF format for several reasons: + +- *PDFs are self-contained.* A PDF is a single file that contains all the images, fonts, and other data needed to render it. It's easy to pass around a PDF. A PDF is unlikely to be missing some critical dependency on your computer. + +- *PDFs are rendered consistently.* A PDF specifies precisely how it should be rendered, so a PDF author can be confident that a reader will see the same document under any conditions. + +- *PDFs are stable over time.* PDFs from decades ago still render the same today. PDFs have a #link("https://www.iso.org/standard/75839.html")[relatively stable standard]. PDFs cannot be easily edited. + +Yet, in the 32 years since the initial release of PDF, a lot has changed. People print out documents less and less. People use phones, tablets, and e-readers to read digital documents. The internet happened; web browsers now provide a platform for rendering rich documents. These changes have laid bare the limitations of PDF: + +- *PDFs cannot easily adapt to different screen sizes.* Most PDFs are designed to mimic 8.5x11" paper (or worse, #link("https://en.wikipedia.org/wiki/PDF#/media/File:Seitengroesse_PDF_7.png")[145,161 km#super[2]]). These PDFs are readable on a computer monitor, but they are less readable on a tablet, and far less readable on a phone. + +- *PDFs cannot be easily understood by programs.* A plain PDF is just a scattered sequence of lines and characters. For accessibility, screen readers #link("https://dl.acm.org/doi/10.1145/2851581.2892588")[may not know] which order to read through the text. For data extraction, scraping tables out of a PDF is an #link("https://openaccess.thecvf.com/content/CVPR2022/html/Smock_PubTables-1M_Towards_Comprehensive_Table_Extraction_From_Unstructured_Documents_CVPR_2022_paper.html")[open] #link("https://ieeexplore.ieee.org/document/5277546")[area] of #link("https://www.sciencedirect.com/science/article/pii/S030645731830205X?casa_token=jNV6uhUNLs0AAAAA:p6EMBh3X54Ulv9Ghtca1WPR2iL6fkhpVOVsbXj7zzinRYVa72HUGQb6VBOIPFdFoHwjEGDSB")[research]. + +- *PDFs cannot easily express interaction.* PDFs were primarily designed as static documents that cannot react to user input beyond filling in forms. + +These pros and cons can be traced back to one key fact: the PDF representation of a document is fundamentally unstructured. A PDF consists of commands like: + +#figure[ + ``` + Move the cursor to the right by 0.5 inches. + Set the current font color to black. + Draw the text "Hello World" at the current position. + ``` +] + +PDF commands are unstructured because a document's organization is only clear to a person looking at the rendered document, and not clear from the commands themselves. Reflowing, accessibility, data extraction, and interaction _all_ rely on programmatically understanding the structure of a document. Hence, these aspects are not easy to integrate with PDFs. + +This raises the question: *how can we design digital documents with the benefits of PDFs but without the limitations?* + += Can't We Just Fix PDF? + +A simple answer is to improve the PDF format. After all, we already have billions of PDFs — why reinvent the wheel? + +The designers of PDF are well aware of its limitations. I carefully hedged each bullet with "easily", because PDF does make it _possible_ to overcome each limitation, at least partially. PDFs can be annotated with their #link("https://opensource.adobe.com/dc-acrobat-sdk-docs/library/pdfmark/pdfmark_Logical.html")[logical structure] to create a #link("https://www.washington.edu/accesstech/documents/tagged-pdf/")[tagged PDF]. Most PDF exporters will not add tags automatically — the simplest option is to use Adobe's subscription-only #link("https://www.adobe.com/acrobat/acrobat-pro.html")[Acrobat Pro], which provides an "Automatically tag PDF" action. For example, here is #link("https://arxiv.org/abs/2310.04368")[a recent paper of mine] with added tags: + +#figure( + image("img/tags.jpg"), + caption: [A LaTeX-generated paper with automatically added tags.], +) + +If you squint, you can see that the logical structure closely resembles the HTML document model. The document has sections, headings, paragraphs, and links. Adobe characterizes the logical structure as an accessibility feature, but it has other benefits. You may be surprised to know that Adobe Acrobat allows you to reflow tagged PDFs at different screen sizes. You may be unsurprised to know that reflowing does not always work well. For example: + +#figure[ + #figure( + image("img/before-resize.jpg"), + caption: [A section of the paper in its default fixed layout. Note that the second paragraph is wrapped around the code snippet.], + ) + + #figure( + image("img/after-resize.jpg"), + caption: [The same section of the paper after reflowing to a smaller width. Note that the code is now interleaved with the second paragraph.], + ) +] + +In theory, these issues could be fixed. If the world's PDF exporters could be modified to include logical structure. If Adobe's reflowing algorithm could be improved to fix its edge cases. If the reflowing algorithm could be specified, and if Adobe were willing to release it publicly, and if it were implemented in each PDF viewer. And that doesn't even cover interaction! So in practice, I don't think we can just fix the PDF format, at least within a reasonable time frame. + += The Good and Bad of HTML + +In the meantime, we already have a structured document format which can be flexibly and interactively rendered: HTML (and CSS and Javascript, but here just collectively referred to as HTML). The HTML format provides almost exactly the inverse advantages and disadvantages of PDF. + +- *HTML can more easily adapt to different screen sizes.* Over the last 20 years, web developers and browser vendors have created a wide array of techniques for #link("https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design")[responsive design]. +- *HTML can be more easily understood by a program.* HTML provides both an inherent structure plus #link("https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA")[additional attributes] to support accessibility tools. +- *HTML can more easily express interaction.* People have used HTML to produce amazing interactive documents that would be impossible in PDF. Think: #link("https://distill.pub/")[Distill.pub], #link("https://explorabl.es/")[Explorable Explanations], #link("https://ciechanow.ski/")[Bartosz Ciechanowski], and #link("http://worrydream.com/")[Bret Victor], just to name a few. + +Again, these advantages are hedged with "more easily". One can easily produce a convoluted or inaccessible HTML document. But on balance, these aspects are more true than not compared to PDF. However, HTML is lacking where PDF shines: + +- *HTML is not self-contained.* HTML files may contain URL references to external files that may be hosted on a server. One can rarely download an HTML file and have it render correctly without an internet connection. +- *HTML is not always rendered consistently.* HTML's dynamic layout means that an author may not see the same document as a reader. Moreover, HTML layout is not fully specified, so browsers may differ in their implementation. +- *HTML is not fully stable over time.* Browsers try to maintain backwards compatibility (#link("https://www.spacejam.com/1996/")[come on and slam!]), but the HTML format is still evolving. The #link("https://html.spec.whatwg.org/")[HTML standard] is a "living standard" due to the rapidly changing needs and feature sets of modern browsers. + +So I've been thinking: *how can we design HTML documents to gain the benefits of PDFs without losing the key strengths of HTML?* The rest of this document will present some early prototypes and tentative proposals in this direction. + += Self-Contained HTML with EPUB + +First, how can we make HTML documents self-contained? This is an old problem with many potential solutions. #link("https://en.wikipedia.org/wiki/WARC_(file_format)")[WARC], #link("https://en.wikipedia.org/wiki/Webarchive")[webarchive], and #link("https://en.wikipedia.org/wiki/MHTML")[MHTML] are all file formats designed to contain all the resources needed to render a web page. But these formats are more designed for snapshotting an existing website, rather than serving as a single source of truth for a web document. From my research, the most sensible format for this purpose is EPUB. + +EPUB is a "distribution and interchange format for digital publications and documents", per the #link("https://www.w3.org/TR/epub-overview-33/#")[EPUB 3 Overview]. Reductively, an EPUB is a ZIP archive of web files: HTML, CSS, JS, and assets like images and fonts. On a technical level, what distinguishes EPUB from archival formats is that EPUB includes well-specified files that describe metadata about a document. On a social level, EPUB appears to be the HTML publication format with the most adoption and momentum in 2024, compared to moribund formats like #link("https://en.wikipedia.org/wiki/Mobipocket")[Mobi]. + +The #link("https://www.w3.org/TR/epub-33")[EPUB spec] has all the gory details, but to give you a rough sense, a sample EPUB might have the following file structure: + +#figure[ + ``` + sample.epub + ├── META-INF + │ └── container.xml + └── EPUB + ├── package.opf + ├── nav.xhtml + ├── chapter1.xhtml + ├── chapter2.xhtml + └── img + └── sample.jpg + ``` +] + +An EPUB contains #link("https://www.w3.org/TR/epub-33/#sec-contentdocs")[content documents] (like `chapter1.xhtml` and `chapter2.xhtml`) which contain the core HTML content. Content documents can contain relative links to assets in the EPUB, like `img/sample.jpg`. The #link("https://www.w3.org/TR/epub-33/#sec-nav")[navigation document] (`nav.xhtml`) provides a table of contents, and the #link("https://www.w3.org/TR/epub-33/#sec-package-doc")[package document] (`package.opf`) provides metadata about the document. These files collectively define one "rendition" of the whole document, and the #link("https://www.w3.org/TR/epub-33/#sec-container-metainf-container.xml")[container file] (`container.xml`) points to each rendition contained in the EPUB. + +The EPUB format optimizes for machine-readable content and metadata. HTML content is required to be in XML format (hence, #strong[X]HTML). Document metadata like the title and author is provided in structured form in the package document. The navigation document has a carefully prescribed tag structure so the TOC can be consistently extracted. + +Overall, EPUB's structured format makes it a solid candidate for a single-file HTML document container. However, EPUB is not a silver bullet. EPUB is quite permissive in what kinds of content can be put into a content document. + +For example, a major issue for self-containment is that EPUB content can embed external assets. A content document can legally include an image or font file whose `src` is a URL to a hosted server. This is not hypothetical, either; as of the time of writing, Google Doc's EPUB exporter will emit CSS that will `@include` external Google Fonts files. The problem is that such an EPUB will not render correctly without an internet connection, nor will it render correctly if Google changes the URLs of its font files. + +#par[#definition[ + Hence, I will propose a new format which I call a #defined-word(id: "portable-epub")[*portable EPUB*], which is an EPUB with additional requirements and recommendations to improve PDF-like portability. The first requirement is: +]] + +#callout[ + *Local asset requirement:* All assets (like images, scripts, and fonts) embedded in a content document of a portable EPUB must refer to local files included in the EPUB. Hyperlinks to external files are permissible. +] + += Consistency vs. Flexibility in Rendering + +There is a fundamental tension between consistency and flexibility in document rendering. A PDF is consistent because it is designed to render in one way: one layout, one choice of fonts, one choice of colors, one pagination, and so on. Consistency is desirable because an author can be confident that their document will look good for a reader (or at least, not look bad). Consistency has subtler benefits --- because a PDF is chunked into a consistent set of pages, a passage can be cited by referring to the page containing the passage. + +On the other hand, flexibility is desirable because people want to read documents under different conditions. Device conditions include screen size (from phone to monitor) and screen capabilities (E-ink vs. LCD). Some readers may prefer larger fonts or higher contrasts for visibility, alternative color schemes for color blindness, or alternative font faces for #link("https://opendyslexic.org/")[dyslexia]. Sufficiently flexible documents can even permit readers to select a level of detail appropriate for their background (#link("https://tomasp.net/coeffects/")[here's an example]). + +Finding a balance between consistency and flexibility is arguably the most fundamental design challenge in attempting to replace PDF with EPUB. To navigate this trade-off, we first need to talk about #defined-word(id: "reading-system")[EPUB reading systems], or the tools that render an EPUB for human consumption. To get a sense of variation between reading systems, I tried rendering this post as an EPUB (without any styling, just HTML) on four systems: #link("https://calibre-ebook.com/")[Calibre], #link("https://www.adobe.com/solutions/ebook/digital-editions.html")[Adobe Digital Editions], #link("https://www.apple.com/apple-books/")[Apple Books], and #link("https://www.amazon.com/dp/B09SWW583J")[Amazon Kindle]. This is how the first page looks on each system (omitting Calibre because it looked the same as Adobe Digital Editions): + +#figure[ + #figure( + image("img/adobe-digital-edition.jpg"), + caption: [Adobe Digital Editions], + ) + + #figure( + image("img/apple-books.jpg"), + caption: [Apple Books], + ) + + #figure( + image("img/kindle.jpg"), + caption: [Amazon Kindle], + ) +] + +Calibre and Adobe Digital Editions both render the document in a plain web view, as if you opened the HTML file directly in the browser. Apple Books applies some styling, using the #link("https://en.wikipedia.org/wiki/New_York_(2019_typeface)")[New York] font by default and changing link decorations. Amazon Kindle increases the line height and also uses my Kindle's globally-configured default font, #link("https://en.wikipedia.org/wiki/Bookerly")[Bookerly]. + +As you can see, an EPUB may look quite different on different reading systems. The variation displayed above seems reasonable to me. But how different is _too_ different? For instance, I was recently reading #link("https://press.uchicago.edu/ucp/books/book/distributed/H/bo70558916.html")[_A History of Writing_] on my Kindle. Here's an example of how a figure in the book renders on the Kindle: + +#figure( + image("img/history-of-writing-kindle.jpg"), + caption: [A figure in the EPUB version of _A History of Writing_ on my Kindle], +) + +When I read this page, I thought, "wow, this looks like crap." The figure is way too small (although you can long-press the image and zoom), and the position of the figure seems nonsensical. I found a PDF version online, and indeed the PDF's figure has a proper size in the right location: + +#figure( + image("img/history-of-writing-pdf.jpg"), + caption: [A figure in the PDF version of _A History of Writing_ on my Mac], +) + +This is not a fully fair comparison, but it nonetheless exemplifies an author's reasonable concern today with EPUB: _what if it makes my document looks like crap?_ + += Principles for Consistent EPUB Rendering + +I think the core solution for consistently rendering EPUBs comes down to this: + ++ The document format (i.e., #def-link("portable-epub")[portable EPUB]) needs to establish a subset of HTML (call it "portable HTML") which could represent most, but not all, documents. ++ Reading systems need to guarantee that a document within the subset will always look reasonable under all reading conditions. ++ If a document uses features outside this subset, then the document author is responsible for ensuring the readability of the document. + +If someone wants to write a document such as this post, then that person need not be a frontend web developer to feel confident that their document will render reasonably. Conversely, if someone wants to stuff the entire Facebook interface into an EPUB, then fine, but it's on them to ensure the document is responsive. + +For instance, one simple version of portable HTML could be described by this grammar: + +#figure[ + ``` + Document ::=
Block*
+ Block ::=

Inline*

|
Block*
+ Inline ::= text | Inline* + ``` +] + +The EPUB spec already defines a comparable subset for #link("https://www.w3.org/TR/epub-33/#sec-nav-def-model")[navigation documents]. +I am essentially proposing to extend this idea for content documents, but as a soft constraint rather than a hard constraint. Finding the right subset of HTML will take some experimentation, so I can only gesture toward the broad solution here. + +#callout[ + *Portable HTML rendering requirement:* if a document only uses features in the portable HTML subset, then a #def-link("portable-epub")[portable EPUB] reading system must guarantee that the document will render reasonably. +] + +#callout[ + *Portable HTML generation principle:* when possible, systems that generate #def-link("portable-epub")[portable EPUB] should output portable HTML. +] + +A related challenge is to define when a particular rendering is "good" or "reasonable", so one could evaluate either a document or a reading system on its conformance to spec. For instance, if document content is accidentally rendered in an inaccesible location off-screen, then that would be a bad rendering. A more aggressive definition might say that any rendering which violates accessibility guidelines is a bad rendering. Again, finding the right standard for rendering quality will take some experimentation. + +If an author is particularly concerned about providing a single "canonical" rendering of their document, one fallback option is to provide a #link("https://www.w3.org/TR/epub-33/#sec-fixed-layouts")[fixed-layout rendition]. The EPUB format permits a rendition to specify that it should be rendered in fixed viewport size and optionally a fixed pagination. A fixed-layout rendition could then manually position all content on the page, similar to a PDF. Of course, this loses the flexibility of a reflowable rendition. But an EPUB could in theory provide #link("https://www.w3.org/TR/epub-multi-rend-11/")[multiple renditions], offering users the choice of whichever best suits their reading conditions and aesthetic preferences. + +#callout[ + *Fixed-layout fallback principle:* systems that generate #def-link("portable-epub")[portable EPUB] can consider providing both a reflowable and fixed-layout rendition of a document. +] + +It's possible that the reading system, the document author, and the reader can each express preferences about how a document should render. If these preferences are conflicting, then the renderer should generally prioritize the reader over the author, and the author over the reading system. This is an ideal use case for the "cascading" aspect of CSS: + +#callout[ + *Cascading styles principle:* both documents and reading systems should express stylistic preferences (such as font face, font size, and document width) as CSS styles which can be overriden (e.g., do not use `!important`). The reading system should load the CSS rules such that the priority order is reading system styles < document styles < reader styles. +] + += A Lighter EPUB Reading System + +The act of working with PDFs is relatively fluid. I can download a PDF, quickly open it in a PDF reading system like #link("https://en.wikipedia.org/wiki/Preview_(macOS)")[Preview], and keep or discard the PDF as needed. But EPUB reading systems feel comparatively clunky. Loading an EPUB into Apple Books or Calibre will import the EPUB into the application's library, which both copies and potentially decompresses the file. Loading an EPUB on a Kindle requires waiting several minutes for the #link("https://www.amazon.com/sendtokindle")[Send to Kindle] service to complete. + +Worse, EPUB reading systems often don't give you appropriate control over rendering an EPUB. For example, to emulate the experience of reading a book, most reading systems will chunk an EPUB into pages. A reader cannot scroll the document but rather "turn" the page, meaning textually-adjacent content can be split up between pages. Whether a document is paginated or scrolled should be a reader's choice, but 3/4 reading systems I tested would only permit pagination (Calibre being the exception). + +Therefore I decided to build a lighter EPUB reading system, #link("https://github.com/nota-lang/bene/")[Bene]. You're using it right now. This document is an EPUB — you can download it by clicking the button in the top-right corner. The styling and icons are mostly borrowed from #link("https://github.com/mozilla/pdf.js")[pdf.js]. Bene is implemented in #link("https://tauri.app/")[Tauri], so it can work as both a desktop app and a browser app. Please appreciate this picture of Bene running as a desktop app: + +#figure( + image("img/bene.png"), + caption: [The Bene reading system running as a desktop app. Wow! It works!], +) + +Bene is designed to make opening and reading an EPUB feel fast and non-committal. The app is much quicker to open on my Macbook (\<1sec) than other desktop apps. It decompresses files on-the-fly so no additional disk space is used. The backend is implemented in Rust and compiled to Wasm for the browser version. + +The general design goal of Bene is to embody my ideals for a #def-link("portable-epub")[portable EPUB] reader. That is, a utilitarian interface into an EPUB that satisfies my additional requirements for portability. Bene allows you to configure document rendering by changing the font size (try the +/- buttons in the top bar) and the viewer width (if you're on desktop, move your mouse over the right edge of the document, and drag the handle). Long-term, I want Bene to also provide richer document interactions than a standard EPUB reader, which means we must discuss scripting. + += Defensively Scripting EPUBs + +To some people, the idea of code in their documents is unappealing. Last time one of my #link("https://nota-lang.org/")[document-related projects] was posted to Hacker News, the #link("https://news.ycombinator.com/item?id=37951616")[top comment] was complaining about dynamic documents. The sentiment is understandable — concerns include: + +- *Bad code:* your document shouldn't crash or glitch due to a failure in a script. +- *Bad browsers:* your document shouldn't fail to render when a browser updates. +- *Bad actors:* a malicious document shouldn't be able to pwn your computer. +- *Bad interfaces:* a script shouldn't cause your document to become unreadable. + +Yet, document scripting provides many opportunities for improving how we communicate information. For one example, if you haven't yet, try hovering your mouse over any instance of the term portable EPUB (or long press it on a touch screen). You should see a tooltip appear with the term's definition. The goal of these tooltips is to simplify reading a document that contains a lot of specialized notation or terminology. If you forget a definition, you can quickly look it up without having to jump around. + +The key design challenge is how to permit useful scripting behaviors while limiting the downsides of scripting. One strategy is as follows: + +#callout[ + *Structure over scripts principle:* documents should prefer structural annotations over scripts where possible. Documents should rely on reading systems to utilize structure where possible. +] + +As an example of this principle, consider how the portable EPUB definition and references are expressed in this document: + +#figure[ + #figure( + ```html +

Hence, I will propose a new format which I call a portable EPUB, which is an EPUB with additional requirements and recommendations to improve PDF-like portability. The first requirement is:

+ ```, + caption: [Creating a definition], + ) + + #figure( + ```html + For one example, if you haven't yet, try hovering your mouse over any instance of the term portable EPUB (or long press it on a touch screen). + ```, + caption: [Referencing a definition], + ) +] + +The definition uses the #link("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dfn")[``] element wrapped in a custom `` element to indicate the scope of the definition. The reference to the definition uses a standard anchor with an addition `data-target` attribute to emphasize that a definition is being linked. The document itself does not provide a script. The Bene reading system automatically detects these annotations and provides the tooltip interaction. + += Encapsulating Scripts with Web Components + +But what if a document wants to provide an interactive component that isn't natively supported by the reading system? For instance, I have recently been working with *The Rust Programming Language*, a textbook that explains the different features of Rust. It contains a lot of passages #link("https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing")[like this one:] + +#figure[ + ```rust + let x = 5; + let x = x + 1; + { + let x = x * 2; + println!("The value of x in the inner scope is: {x}"); + } + println!("The value of x is: {x}"); + } + ``` + + This program first binds `x` to a value of `5`. Then it creates a new variable `x` by repeating `let x =`, taking the original value and adding `1` so the value of `x` is then `6`. Then, within an inner scope created with the curly brackets, the third `let` statement also shadows `x` and creates a new variable, multiplying the previous value by `2` to give `x` a value of `12`. When that scope is over, the inner shadowing ends and `x` returns to being `6`. When we run this program, it will output the following: +] + +A challenge in reading this passage is finding the correspondences between the prose and the code. An interactive code reading component can help you track those correspondences, like this (try mousing-over or clicking-on each sentence): + +//
+// +//
fn main() {
+//     let x = 5;
+//     let x = x + 1;
+//     {
+//         let x = x * 2;
+//         println!("The value of x in the inner scope is: {x}");
+//     }
+//     println!("The value of x is: {x}");
+// }
+//

+// This program first binds x to a value of 5. +// Then it creates a new variable x by repeating let x =, +// taking the original value and adding 1 +// so the value of x is then 6. +// Then, within an inner scope created with the curly brackets, +// the third let statement also shadows x and creates +// a new variable, +// multiplying the previous value by 2 +// to give x a value of 12. +// When that scope is over, the inner shadowing ends and x returns to being 6. +//

+//
+//
+ +#figure[ + #code-description[ + + fn main() { + + let #code-def("code-1")[x] = #code-def("code-2")[5]; + + #code-def("code-4")[let #code-def("code-3")[x] =] #code-def("code-15")[x] #code-def("code-16")[+] #code-def("code-5")[1]; + + #code-def("code-7")[{] + + #code-def("code-8")[let] #code-def("code-9")[x] = #code-def("code-10")[x] #code-def("code-17")[\*] #code-def("code-11")[2]; + + println!("The value of x in the inner scope is: {x}"); + + #code-def("code-13")[}] + + println!("The value of x is: {#code-def("code-14")[x]}"); + + } + ] + + #par[ + #code-steps[ + + This program first binds #link("#code-1")[`x`] to a value of #link("#code-2")[`5`]. + + Then it creates a new variable #link("#code-3")[`x`] by repeating #link("#code-4")[`let x =`], + + taking #link("#code-15")[the original value] and #link("#code-16")[adding] #link("#code-5")[1] so the value of #link("#code-3")[`x`] is then 6. + + Then, within an #link("#code-7")[inner scope] created with the #link("#code-7")[curly] #link("#code-13")[brackets], + + the third #link("#code-8")[`let`] statement also shadows #link("#code-3")[`x`] and creates #link("#code-9")[a new variable], + + #link("#code-17")[multiplying] #link("#code-10")[the previous value] by #link("#code-11")[2] to give #link("#code-9")[`x`] a value of 12. + + When #link("#code-7")[that scope] #link("#code-13")[is over], #link("#code-9")[the inner shadowing] ends and #link("#code-14")[`x`] returns to being 6. + ] + ] +] + +The interactive code description component is used as follows: + +#figure[ + ```html + +
fn main() {
+      let x = 5;
+      
+  }
+

+ This program first binds x to a value of 5. + +

+
+ ``` +] + +Again, the document content contains no actual script. It contains a custom element ``, and it contains a series of annotations as spans and anchors. The `` element is implemented as a #link("https://developer.mozilla.org/en-US/docs/Web/API/Web_components")[web component]. + +Web components are a programming model for writing encapsulated interactive fragments of HTML, CSS, and Javascript. Web components are one of many ways to write componentized HTML, such as #link("https://react.dev/")[React], #link("https://www.solidjs.com/")[Solid], #link("https://svelte.dev/")[Svelte], and #link("https://angular.io/")[Angular]. I see web components as the most suitable as a framework for portable EPUBs because: + +- *Web components are a standardized technology.* Its key features like #link("https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements")[custom elements] (for specifying the behavior of novel elements) and #link("https://dom.spec.whatwg.org/#shadow-trees")[shadow trees] (for encapsulating a custom element from the rest of the document) are part of the official HTML and DOM specifications. This improves the likelihood that future browsers will maintain backwards compatibility with web components written today. +- *Web components are designed for tight encapusulation.* The shadow tree mechanism ensures that styling applied within a custom component cannot accidentally affect other components on the page. +- *Web components have a decent ecosystem to leverage.* As far as I can tell, web components are primarily used by Google, which has created notable frameworks like #link("https://lit.dev")[Lit]. +- *Web components provide a clear fallback mechanism.* If a renderer does not support Javascript, or if a renderer loses the ability to render web components, then an HTML renderer will simply ignore custom tags and render their contents. + +Thus, I propose one principle and one requirement: + +#callout[ + *Encapsulated scripts principle:* interactive components should be implemented as web components when possible, or otherwise be carefully designed to avoid conflicting with the base document or other components. +] + +#callout[ + *Components fallback requirement:* interactive components must provide a fallback mechanism for rendering a reasonable substitute if Javascript is disabled. +] + += Where To Go From Here? + +Every time I have told someone "I want to replace PDF", the statement has been met with extreme skepticism. Hopefully this document has convinced you that HTML-via-EPUB could potentially be a viable and desirable document format for the future. + +My short-term goal is to implement a few more documents in the #def-link("portable-epub")[portable EPUB] format, such as my #link("https://willcrichton.net/nota")[PLDI paper]. That will challenge both the file format and the reading system to be flexible enough to support each document type. In particular, each document should look good under a range of reading conditions (screen sizes, font sizes and faces, etc.). + +My long-term goal is to design a document language that makes it easy to generate #def-link("portable-epub")[portable EPUBs]. Writing XHTML by hand is not reasonable. I designed #link("https://nota-lang.org/")[Nota] before I was thinking about EPUBs, so its next iteration will be targeted at this new format. + +If you have any thoughts about how to make this work or why I'm wrong, let me know by #link("mailto:crichton.will@gmail.com")[email] or #link("https://twitter.com/tonofcrates")[Twitter] or #link("https://mastodon.social/@tonofcrates")[Mastodon] or wherever this gets posted. If you would like to help out, please reach out! This is just a passion project in my free time (for now...), so any programming or document authoring assistance could provide a lot of momentum to the project. + += But What About... + +A brief postscript for a few things I haven't touched on. + +*...security?* You might dislike the idea that document authors can run arbitrary Javascript on your personal computer. But then again, you presumably use both a PDF reader and a web browser on the daily, and those both run Javascript. What I'm proposing is not really any _less_ secure than our current state of affairs. If anything, I'd hope that browsers are more battle-hardened than PDF viewers regarding code execution. Certainly the designers of EPUB reading systems should be careful to not give documents any _additional_ capabilities beyond those already provided by the browser. + +*...privacy?* Modern web sites use many kinds of telemetry and cookies to track user behavior. I strongly believe that EPUBs should not follow this trend. Telemetry must _at least_ require the explicit consent of the user, and even that may be too generous. Companies will inevitably do things like offer discounts in exchange for requiring your consent to telemetry, similar to Amazon's #link("https://www.amazon.com/gp/help/customer/display.html?nodeId=GFNWCZJAM3SBQQZD")[Kindle ads policy]. Perhaps it is better to preempt this behavior by banning all tracking. + +*...aesthetics?* People often intuit that LaTeX-generated PDFs look prettier than HTML documents, or even prettier than PDFs created by other software. This is because Donald Knuth took his job #link("https://www-cs-faculty.stanford.edu/~knuth/dt.html")[very seriously]. In particular, the #link("https://onlinelibrary.wiley.com/doi/abs/10.1002/spe.4380111102?")[Knuth-Plass line-breaking algorithm] tends to produce better-looking justified text than whatever algorithm is used by browsers. + +There's two ways to make progress here. One is for browsers to provide more typography tools. Allegedly, `text-wrap: pretty` is #link("https://developer.chrome.com/blog/css-text-wrap-pretty/")[supposed to help], but in my brief testing it doesn't seem to improve line-break quality. The other way is to #link("https://mpetroff.net/2020/05/pre-calculated-line-breaks-for-html-css/")[pre-calculate line breaks], which would only work for fixed-layout renditions. + +*...page citations?* I think we just have to give up on citing content by pages. Instead, we should mandate a consistent numbering scheme for block elements within a document, and have people cite using that scheme. (Allison Morrell #link("https://twitter.com/AllisonDMorrell/status/1750728545905823856")[points out] this is already the standard in the Canadian legal system.) For example, Bene will auto-number all blocks. If you're on a desktop, try hovering your mouse in the left column next to the top-right of any paragraph. + +*...annotations?* Ideally it should be as easy to mark up an EPUB as a PDF. The #link("https://www.w3.org/TR/annotation-model/#selectors")[Web Annotations specification] seems to be a good starting point for annotating EPUBs. Web Annotations seem designed for annotations on "targetable" objects, like a labeled element or a range of text. It's not yet clear how to deal with free-hand annotations, especially on reflowable documents. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot1.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot1.jpg new file mode 100644 index 0000000..15ec7c6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot1.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot2.png new file mode 100644 index 0000000..86ef4bd Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot3.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot3.jpg new file mode 100644 index 0000000..5748da6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e1-shot3.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-apple-II.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-apple-II.jpg new file mode 100644 index 0000000..34b4c3b Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-apple-II.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-bell-works.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-bell-works.png new file mode 100644 index 0000000..21af54e Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-bell-works.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-commodore-pet.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-commodore-pet.jpg new file mode 100644 index 0000000..a3a14cd Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-commodore-pet.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-ibm-pc.jpg b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-ibm-pc.jpg new file mode 100644 index 0000000..5fdd501 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-ibm-pc.jpg differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-lumon-logo.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-lumon-logo.png new file mode 100644 index 0000000..ef615ce Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-lumon-logo.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-shot1.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-shot1.png new file mode 100644 index 0000000..00d9360 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-shot1.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-shot2.png new file mode 100644 index 0000000..b0084f6 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e2-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-shot1.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-shot1.png new file mode 100644 index 0000000..dfa281e Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-shot1.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-shot2.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-shot2.png new file mode 100644 index 0000000..ba39e37 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-shot2.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-tamingtempers.png b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-tamingtempers.png new file mode 100644 index 0000000..6eaac35 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/severance-s1e3-tamingtempers.png differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/typst-links-citations-demo.mp4 b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/typst-links-citations-demo.mp4 new file mode 100644 index 0000000..a29ea41 Binary files /dev/null and b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/img/typst-links-citations-demo.mp4 differ diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/index.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/index.typ new file mode 100644 index 0000000..e80f8be --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/index.typ @@ -0,0 +1,37 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Main blog index page with post listings + +#let div(_class: "", ..body) = html.elem("div", attrs: (class: _class), ..body) +#let br() = html.elem("br") +#let hr() = html.elem("hr") +#let ul(_class: "", ..body) = html.elem("ul", attrs: (class: _class), ..body) +#let li(_class: "", ..body) = html.elem("li", attrs: (class: _class), ..body) + +#let template(doc) = { + doc + context if target() == "html" or target() == "epub" { + div[ + #br() + #hr() + #ul[ + #li[#link("./index.typ")[Home]] + #li[#link("https://lachlankermode.com")[Learn more] about me] + #li[#link("https://ohrg.org")[Read other musings]] + ] + ] + } +} + +#show: template + += Screening the subject + +_Screening the subject_ is a blog that analyses content on both the big and small screen in reasonable detail, i.e. episode-by-episode or scene-by-scene. +Contact us at #link("mailto:info@ohrg.org")[info\@ohrg.org] for enquiries. + +// Be alerted of new content by subscribing to the #link("https://screening-the-subject.ohrg.org/feed.xml")[RSS feed]. + +- #link("./severance-ep-1.typ")[Severance, s1/e1] +- #link("./severance-ep-2.typ")[Severance, s1/e2] +- #link("./severance-ep-3.typ")[Severance, s1/e3] diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/references.bib b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/references.bib new file mode 100644 index 0000000..f8543cd --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/references.bib @@ -0,0 +1,30 @@ +@article{freudTotemTabooResemblances1919, + title = {Totem and {{Taboo}}: {{Resemblances Between}} the {{Psychic Lives}} of {{Savages}} and {{Neurotics}}}, + author = {Freud, Sigmund}, + translator = {Brill, A.A}, + year = {1919}, + journal = {Moffat, Yard and Company}, + volume = {50}, + number = {1}, + pages = {94--95}, + publisher = {LWW}, + urldate = {2025-06-05} +} + +@misc{lacanFamilyComplexesFormation2002, + title = {Family {{Complexes}} in the {{Formation}} of the {{Individual}}}, + author = {Lacan, Jacques}, + year = {2002}, + publisher = {Antony Rowe London}, + urldate = {2025-05-22}, + file = {/home/lox/Zotero/storage/HAKMXWZ5/Lacan - 2002 - Family Complexes in the Formation of the Individual.pdf} +} + +@article{mcgowanDistributionEnjoyment2021, + title = {The {{Distribution}} of {{Enjoyment}}}, + author = {McGowan, Todd}, + year = {2021}, + journal = {European Journal of Psychoanalysis}, + volume = {8}, + number = {1} +} diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-1.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-1.typ new file mode 100644 index 0000000..cd00d88 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-1.typ @@ -0,0 +1,111 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post with images, footnotes, and bibliography + +#import "index.typ": template +#show: template + +#set document(title: [Good news about hell - #emph[Severance] [s1/e1]]) + +#title() + +#image("img/severance-s1e1-shot1.jpg") + +The first thing to notice is the colour palette. +She is dressed in blue, but her hair is chestnut red. +It spills out for the frame of her figure into the table around it, blockaded at its border by chairs and a carpet clad in green, yellow, then green again; then gray. +The establishing shot is a bird's eye view of an unknown woman who is soon revealed to have been put in the board room by someone else's design, who learns about her predicament only by a man's voice that emanates from the little device that rests on the table along with the woman, arranged so that it aims directly at her head. + +This opening image is a graph of the subject's predicament on the severed floor at Lumon. +Blue is the company colour. +Employees are almost invariably dressed in shades of it-- navy, midnight, Prussian, Oxford, cobalt-- and more reliably so as we work our way up the hierarchy. +Red is unruly passion, the tone of tempers that itch to tear off the straitjacket directives, to disregulate the business-as-usual in which there is no obvious place for illicit activities. +Green is the accent of Macro Data Refinement, the division of Lumon in which the show's protagonists are employed. +The device directs a man's voice at a woman's body in an attempt to keep her tempers in check, to ensure her firecraft does not smoke out the staid edifice of personality management, to order her #quote[perceptual chronologies] accordingly. +(Later in the episode, we learn that she almost manages to #quote[break in] on the control room during that opening sequence: the solidity of its enclosure is threatened from the very first.) + +It is instructive to attempt to articulate the dynamics that this graph indexes before we start talking about other scenes in the show. +Graphs are not at one with what they represent, for in the decision to render 'data' in the very act of a representation, we both lose and gain distinction of the dynamics in question. +The voice that opens Helly R up to the world of Lumon's severed floor begins: #quote[Who are you?] +This question is a mistake. +We retroactively learn, in a later scene, that Mark S was in fact supposed to begin with a less interrogative, more perfunctory: #quote[Hi there, you on the table. I wonder if you'd mind taking a brief survey.] +As Irving puts it: #quote[You [Mark S] skipped the preamble]. +Helly R is thrust, by this accident, immediately into questioning not only herself, but also the self-assurance of the voice that interrogates her. +Does this voice in my head [she could be thinking] really know what it is doing? +Or is it just a role of similarly confused actors struggling to stick to a badly written script? + +#link("https://www.youtube.com/watch?v=QIsLXuVeUgM")[This episode-length recap] of the first episode names this graph 'the Helly incident', a poorly executed orientation of Helly's newfound subjectivity that can be blamed at one level on Mark S (for starting with the wrong part of the manual), at another on Mr. Milchick (for misguiding Mark while he was distracted setting up the visual feed), on Ms. Cobel (for giving Mark Petey K's old manual without redacting his obscurely scribbled notes and paper bookmarks), or even on Irving (for neglecting to intervene and clarify how Mark should begin being the more senior refiner in the situation: #quote[Irving will be there to shadow. Just stick to the flowchart and escalate properly depending on dialectics.]). +Wherever to place blame, there is doubtless a misconfiguration that takes place. +Helly's instinctual reaction seems to be to try to kill the voice pointed at her head, rather than to befriend it as Mark states he did (where Petey was Mark). +(Helly will eventually have sex with the source of the voice, rather than murdering or fraternizing with it.) +In this episode, however, Mark (the voice's source) is physically assaulted by Helly, dented in his temple by the same vocalization device that mediated their first communication. + +#image("img/severance-s1e1-shot2.png") + +So this is the Macro Data refiner's situation. +On the one hand, she is affronted with a voice that compels her to abide by the rules and permits her to enjoy some small reliefs (egress from a locked room) if she concedes to it. +On the other, she is always teeming and thus flirting with red, considering escape routes that involve drawing blood, setting off alarms, or removing clothes. + +This unruly red is what Macro Data Refinement's greening procedures are supposed to contain to produce a completely controlled and scripted blot of blue. +Perhaps this is why the glipse of the vacant desks planned for the severed floor's expansion are draped in purple, for that shade of subjectivity would better incorporate the contrasting contours into a unified and taskable tone. +The red that threatens Lumon's corporate, calm, and collected blue (the Lumon logo is a water droplet that suspiciously resembles a camera) is splattered across scenes in the episode. +It is, for example, the envelope that Petey slips Mark at the company-owned restaurant #emph[Pip's] with the suggestion that he should read it if he wants to know #quote[what's going on down there]. +It is the sweater Mark wears to his sister's dinnerless dinner party, punctuated by red place mats (#quote[what a lot of people overlook, I think, is that life is not food]), where the ontological substance of his innie is called into question, and where we learn about the passions he has lost-- the history of World War II, educating, whiskey-- the last of which seems to have given way to an indiscriminate consumption of beer, wine, anything that will drown out the clarity of sober consciousness. +It is the general hue of his sister's house, which consisently wants him to question that placid blue of his company-subsidized housing at Baird Creek Manor. + +This dinner tells us something more about the subject in question in #emph[Severance]. +Just as Helly's outie had alerted us to the basic principle in the video her innie was shown in curiously lo-fi resolution to conclude her innie's orientation-- #quote[perceptual chronologies… surgically split]-- Mark's predicament is comparably explained to him by another more or less ignorant (we can't help but imagine) third party: #quote[One's memories are bifurcated, so when you're at work, you have no recollection of what it is you do there.] +As pretentious as they are, the dinner's guests do seem to be attuned to an important dimension of the meaning of life, which is that it can't #emph[only] be about satiating biological needs such as food. +What each individual 'needs' is a disharmonious melange of needs and demands, openings of desire that emerge not only through a graph of bare necessities-- food, water, shelter-- but also through capricious carapaces that emerge from more ambiguous pinings in the social sphere-- company, care, love. +The real question of Lumon's smooth functioning is whether it will be able to effectively plug up these pinings, the incidental moments at work where one wonders what one is really doing with one's life, whether the company can really manage its employees' unsanctioned thoughts and the way in which those illicit ideas seep into the daily practice of their workerhood. +More on the plasticity of our needs and drives to satisfy them in later posts. + +#image("img/severance-s1e1-shot3.jpg") + +Ms. Cobel, in contrast to Helly's and Mark's doubtful and doubting red, is a stormy and icy blue. +(We must wait until season two to uncover the historical and psychological depth of this colour for Harmony Cobel.) +She is the figure with a body that seems to be the most in charge, of those we meet in this episode. +Though Ms. Cobel is not a master in herself, it seems, for she too is subjected to a disembodied voice-via-device, 'the board', albeit which only appears evidently as an ear so far (#quote[The board won't be contributing to this meeting vocally]). +Cobel is responsible for keeping the severed floor's uncertainty in check, the 'head' that sits atop the variegated limbs of its disobedient body. + +When Cobel reprimands Mark for his derailing of Helly's orientation, she recalls an obscure and theological aspect of her parentage: + +#quote(block: true)[ + You know, my mother was an atheist. + She used to say that there was good news and bad news about hell. + The good news is, hell is just the product of a morbid human imagination. + The bad news is, whatever humans can imagine, they can usually create. +] + +At the close of the episode, just before Mark's senile neighbor Mrs. Selvig (who we have only heard about through Mark's voice thus far, when he is on the phone with her) visually reveals herself to be the same woman as Ms. Cobel, she gives a slightly different account of her heritage: + +#quote(block: true)[ + You know, my mother was a Catholic. + She used to say it takes the saints eight hours to bless a sleeping child. + I hope you aren't rushing the saints. +] + +It's unclear at this point whether Cobel is a severed worker like Mark, or whether there is some other reason for her (strange, almost senseless) duplicity. +Why lie about the religious leanings of one's mother? +Or maybe 'mother' is actually a name for something else, a kind of interim authority that gives synthetic weight to some hearsay, rumor, or idle phrase. +(The other cameo of an ambiguously defined mother in this episode is in question five of Helly's orientation survey: #quote[To the best of your memory, what is or was the color of your mother's eyes?]) +Perhaps it is that, severed or not, atheist or Catholic, Cobel's subjectivity is structured by a comparable split in her perceptual chronologies, whereby some memories (of her mother) get more airtime in her conscious experience of herself than others. + +#emph[Severance] flirts with this idea extensively, that the innie/outie dyad is analagous to the unconscious/conscious experience that we, as subjects, have of ourselves. +Mark's sister Devon hints at the psycho-logical reading of the severed condition in her diagnosis of Mark's morose (outie) predicament as a state of failed therapy in response to mourning for his late wife: #quote[I just feel like forgetting about her for eight hours a day isn't the same thing as healing.] +As with not-mothers and the plasticity of the drive, we will address the psychoanalytic implications here in later posts; but to finish I want to bring our attention to the #emph[imaging of time] at work in just this first episode. + +The fascinating details of failed synchronisation between all the watchfaces we see are enumerated in #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/txxbcm/observation_and_question_regarding_time/")[this Reddit thread]. +Many of the watch hands appear to be stalled, and the crossover from each to the next-- as when Mark Scout switches his wrist watch in preparation for his elevator descent into the workday of innie Mark S-- doesn't match with our experience of the actors on screen. +One of the few things we do know about the severance procedure is that it 'alters perceptual chronologies', and that this messing with a subject's sense of time is thought to + ++ make them more adequate or productive in a certain kind of work (for why else would Lumon go to the necessary lengths to sever some employees) ++ supposes to section off innie memories and experience from outie memories and experience + +So the subject's subjectivity is marked by its sense of time, and Lumon's success (profitability?) hinges in some way on altering their employees' stable sense of it while in the space of the severed floor. + +Mark S's temporal predicament here has been explained by a man whose last name we get by speeding up the saying of his own, Karl Marx (Mar-k-S). +Logically speaking, Marx argues, there is an amount of time that goes missing in the worker's employment by way of a wage, when he advances some portion of his time to the capitalist in exchange for a pay-check one or more weeks later. +I refer the reader interested in the details to #link("https://www.marxists.org/archive/marx/works/1867-c1/ch20.htm")[chapter 20 of #emph[Capital] Vol. I];: but the essential point here is that it is through an obfuscation of the real value of a worker's time that the capitalist manages to produce surplus-value. +The production of this kind of time-distorted surplus-value is the engine of capitalism as a social relation that appears, on the surface, to be equally fair to capitalist and worker alike. +So the project of controlling 'perceptual chronologies' with which Lumon seems to be so concerned is perhaps not as esoteric and inessential as it might at first seem. Perhaps it is an embodiment of the core ingredient of the company's success as a company, of its incorporation as an entity that ought to be sustained even at the expense of its members' happiness, their health, and their livelihoods. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-2.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-2.typ new file mode 100644 index 0000000..1fcc98b --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-2.typ @@ -0,0 +1,122 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 2 + +#import "index.typ": template +#show: template + +#set document(title: [Half Loop - _Severance_ [s1/e2]]) + +#title() + +In #link("./severance-ep-1.typ")[the first episode], we were introduced to the two-sided subject at Lumon. +On the one hand, there is Mark S, the innie, who is screened for the first and major part of the episode. +On the other, Mark Scout, the outie, to whose predicament we are introduced in the concluding scenes. +S1E2 opens with a rewind on how innie Helly R came to be: how Milchick handed her flowers at end of her first day (which we glimpsed in S1E1 when Mark almost ran her over), a glimpse of her confidence gliding into the operating room on a higher floor of the same Lumon complex we saw Mark leave, a stereoscopic view of the implant procedure by which she becomes an android whose existence is #quote[spatially dictated] by Lumon's mysterious machinations. + += Lumon Industries + +#image("img/severance-s1e2-lumon-logo.png") + +Lumon is a corporate pastiche, and not only of technology companies. +Lumon seems to have its hands in surgical hardware (the operating room equipment), digital technology ('Macrodata Refinement'), and medicines and topical salves (as discussed at the dinner party in S1E1 - #quote[What don\'t they make?]). +It is a quintessentially American jack of all trades, a global power in its own right cohered by a family dynasty---the Eagans---recalling the Du Ponts or the Rockefellers. + +The more obvious comparison to make, however, is between #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/1fb28nq/apple_lumon_are_weridly_similar/")[Lumon and Apple], perhaps in part because the show screens on Apple TV Plus. +The style of the computers on the severed floor recalls #link("https://www.historytools.org/docs/computer-history-timeline-personal-computers-computing-internet")[the dawn of the era of personal computing] in the 1970s and 80s, an aesthetic imaginary in which Apple plays an important role. + +Indeed, the aura of Lumon as a futuristic computing corporation from the late 70s is reinforced by the fact that its headquarters are shot at #link("https://ethw.org/Bell_Labs")[Bell Labs] in New Jersey, a building that has now been renovated as a mixed-use office for high-tech startup companies as #link("https://en.wikipedia.org/wiki/Bell_Labs_Holmdel_Complex")[Bell Works]. +Bell Labs is the quasi-mythological source in the contemporary corporate technology culture (Silicon Valley) of the idea that a certain kind of research freedom characterized by open-ended product delivery timelines and serendipitous encounters in open office plans can cultivate ground-breaking technology. +(Mark Zuckerberg #link("https://www.businessinsider.com/mark-zuckerberg-recommends-the-idea-factory-2015-11")[recommended] a book on Bell Labs as one of his #quote[important books] of 2015.) +The irony of this setting, of course, both in _Severance_ and in the technology companies it parodies in the American landscape in 2025, is that the workplace has never been more saturated with surveillance and micro-management. +The overhead shot of Helly R that opened the series is indicative here again, as is the complementary overhead of MDR's desks we get in this episode: there is always something watching from above, it seems, even if what it captures of the actual activity is a flattened and at times misrepresentative image. + +There are also evocations of Microsoft and IBM in Lumon, such as the #link("https://en.wikipedia.org/wiki/Office_Assistant")[Clippy];-like guide on the manual handed to Helly in the episode, or the apparent requirement of suits on the severed floor echoing #link("https://www.reddit.com/r/AskHistorians/comments/7l9ncw/comment/drkzual/")[IBM's infamous strict dress code]. +Lumon is a melange of imaginary pasts, presents, and futures in American innovation. +It is futuristic in the framing of its bio-technological project of perceptual management---and in the #quote[data smuggling] detectors that are installed in the elevators to the severed floor, about which more soon---but retrofitted in its aesthetic, in its management style, and in its outdated repertoire of daily devices. +Recall, for example, Milchick's handheld camcorder, and the tube-activated (vacuum-tube?) camera he uses to snap the official photo +of the new group of refiners. + +#image("img/severance-s1e2-bell-works.png") + +The overhead of Lumon Industries itself depicts a sketchy graph of a brain, one can't help but think. +Its upper floors all operate above board with normally conscious workers, whereas underground there is something sensitive enough happening so as to require extra precautions. +In #link("./severance-ep-1.typ")[S1E1's analysis], we introduced the idea that Lumon's interest in severing workers has to do with the mechanics of capital, in that surplus value can only ever be produced (in Marx's account) through the structural theft of time from its laborers.#footnote[#link("https://fi2.zrc-sazu.si/en/sodelavci/bostjan-nedoh-en")[Boštjan Nedoh] has evocatively called this operation #quote[theft without a thief].] +Lumon's spatial layout suggests that there might also be a psychoanalytic metaphor at stake in severance as an operation, where the happenings that occur in the business brain's basement are essential to what it really is, why it does what it does. + +Though Freud's theory has been popularized as a topographical notion, wherein the unconscious is the submerged part of the mind's iceberg of which we only see the tip, there is good reason to believe that this spatial description misrepresents how the unconscious should be properly understood. +Lacan thus preferred _topological_ descriptors to suggest that, if the unconscious is a 'place' or 'site', it contradicts any over-simplistic understanding of spaces that are distinctly separable. +The relationship between the conscious and the unconscious in a psychoanalytic theory of the subject, I would suggest, is better understood through the figure of a coin with two inseparable sides. +The meaning of any one side ('heads') derives from the meaning of its opposite ('tails'); and it is thus insensible to imagine separating one part from the other without repressing something fundamental about the structure of the subject as a whole. + +Lumon, though, seems to want desperately to keep innies from being in contact with their outies. +Indeed, the very project of severance seems to have something fundamental to do with managing repression effectively, with renovating the worker into a perfectly divided self that cannot complain about the conditions of her labor through the fact of not knowing anything about them. +(When Mark is given a dinner coupon on account of his head injury in S1E1, the real cause of the scar---Helly R's riotous attempt to escape the orientation room---is not revealed to outie Mark.) +The subject in _Severance_ is split and maintained as such. +The 'unconscious' of one's home life should not affect one's 'conscious' ability to perform at work. + +The vice-versa is also true. +Outies cannot suffer the 'unconscious' of their innies, either. +Mark Scout's decision to sever himself seems to be an attempt to repress the devastating effect of his experience of his wife's death for some part of the day, given that he admits he was unable to continue his job as a history teacher due to alcoholism. +At Lumon, however, Mark's alcoholism is brutally functional; as his innie must suffer what (lack of) energy he is given by outie Mark's actions the night before (#quote[I find it helps to focus on the effects of sleep since we don't actually get to experience it]). + +The intellectual impoverishment of Lumon's severed workers is further exposed in this episode as Dylan tries to convey to Helly the substance of what there is to live for as a severed innie: his #quote[embarrassment of wealth] that consists of finger traps, a caricature portrait, and the hope that there might be a #quote[waffle party] on the horizon. +The sad satisfactions that severed workers aspire to reinvigorate the sense of the phrase #quote[wage slavery], an important formulation that in fact has solid footing in Marx's analysis of capital. +For Marx, it is worth comparing the wage worker's predicament to the slave's; for both must labor not for themselves, for their own ends and aspirations, but for an external master that appropriates their efforts. +The important distinction is that, while in _actual_ slavery the slave's enthrallment to the master is explicit and explicitly enforced by means of force, in _wage_ slavery the figure of the master is more diffuse, and hierarchical distinctions are 'justified' in the discursive suggestion of their being fairly and freely established. +The proletariat (wage laborer) is free to choose her own master on the market, selling her labor power to whomever she chooses. +But she is not free to refuse to sell her labor as labor-power; as this #quote[wage slavery] is the generalized means of her reproduction and ability to go on living. +So the proletariat is enslaved to a structure, not a person, and that structure is characterized by the reduction of labor in its multifarious forms to labor-power, a measurement of labor in time that thus becomes exchangeable on the market. +In capitalism, in other words, freedom is structurally reduced to the freedom to choose to whom one sells one labor-power: which is #emph[not] the same thing as freedom tout court. +Thus is the wage laborer unfree in a way that is comparable, though not equivalent, to the slave. + += Death at Lumon +The death culture at Lumon should also be doubly refracted through Marx's analysis of how capital reduces its workers to shadows of themselves on the one hand, and a psychoanalytic understanding of the subject on the other. +When Mark gets emotional about Petey's disappearance during the game of office introductions (which tellingly involves passing around a brignt red ball), Milchick reprimands him with the following explanation: + +#quote(block: true)[ + I think this is a good time to remind ourselves that things like deaths happen outside of here. + Not here. + A life at Lumon is protected from such things. + And I think a great potential response to that from all of you is gratitude. +] + +Severed workers are insulated from death because the very structure of their subjectivity distances the meaning of its concept. +Innies symbolically 'die' when their outies do not come back to work, but this event does not necessarily coincide with their physical death, which as Milchick suggests should only be imagined to take place in the world of their outies. +There is a contradiction here, though, as a physical accident at work would propagate through to an innie's outie. +So Milchick's repression of the notion of death must be recognized as just that: a repression of a certain moment in or dimension of logic (a moment that is too dangerous or frightening to imagine saying out loud), and not as an explication of the necessary consequences of a thorough logic of life. + +Milchick's philosophizing also points to something more sinister in the structure of the severed subject. +The severed worker is protected from death, perhaps, because there is a sense in which he is already #emph[undead]. +Doomed to exist in the artificial enclosure of Lumon's basement and placated only by the pathetic enjoyments of finger traps, company coffee, ideological art, and the odd waffle party, what is there, _really_, to live for at Lumon? +The motto briefly shown on the implant hardware in Helly's operating room scene at the episode's opening has a morbid resonance here: #quote[Don't live to work. Work to live.] + +There is a stronger psychoanalytic sense in which we might make sense of Milchick's discourse on death that is worth mentioning here, too. +Lacan articulates a distinction between two kinds of death in his theory of the subject, a first death that is #strong[biological] and a second death that is #strong[symbolic]. +I will explicate this theory later in S1, when Milchick's foreshadowing of death's importance in the show bubbles clearly to the surface in a later episode. + += Capturing and controlling the symbolic +Let's talk about the #quote[symbol detectors] in the elevators, which are introduced in this episode. +These are the real basis of how Lumon separates innies from outies, as they supposedly ensure that no notes, no language, is passed between the two kinds of self. +In S1E1, we saw outie Mark put the tissue he had been crying into in his car in his pocket; and we then saw innie Mark confidently strolling out of the elevator on the severed floor, quizzically discovering the tissue in his pocket, and tossing it into a bin on his walk down the hall to MDR. +So the suggestion has already been planted in our (the viewers') mind that it is #emph[possible] to traffic objects across the boundary. +The other clear evidence of this is offered here in S1E2, where Irving similarly, quizzically, observes the black sooty substance underneath his fingernails during the distraction of the melon party. + +Yet Helly's note to herself triggers the alarms, resulting in the elevator doors refusing to close and a screen washed out with red alert. +So they do seem to have some power to detect 'symbols'. +But what marks the boundary between a symbol and a non-symbol for this technology? +It is not only explicit language in the form of written or spoken words that make meaning for us as human animals. +We are affected by a frightening range of other things; colors, tactile memories, qualities of our past selves that seep into our present (such as too much alcohol drunk the night before). +So it is hard to imagine, knowing the complexities of our selves as we all do, that Lumon could really effectively police the boundary between innies and outies, even with its back-to-the-future technological prowess. + +Indeed, the audio recording that innie/outie Petey shows outie Mark in his hideout at the greenhouse reveals the insecurity of symbol detection at Lumon. +In order to get a recording of what he was subjected to in the Break Room, he must have been able to get that retro handheld device back up into the 'real' world. +So either the elevators weren't able to pick it up, or there is some other way for innies to move between the supposedly demarcated spaces. +Either way, the symbol policing at the innie/outie border seems to have some shortcomings. + +A brief note on Petey's dishevelled greenhouse to conclude, as this episode is where we are first introduced to much of the geography that will become important in the series: the break room, wellness, MDR, optics and design, Mark's basement, the company restaurant (where Mark has his insufferably awkward date), the elevator, the MDR kitchen, the operating room, the Lumon foyer. +Petey's greenhouse, like many of the spaces in #emph[Severance], is a graph that both embodies and reflects a psycho-social moment of the show. +Green like Macrodata Refinement, but much less put-together, the greenhouse reveals the underside of Lumon's apparent glaem, the unconscious damage that its project of perfection wreaks on its workers psychologically and physically. +Petey shows us that the worker, like so many words and things in the show, is not simply what it seems, but consists also of an excess signification that inevitably creeps into its conspicuous comportment. +Mark is a depressed drunkard on the outside, and Irving (it seems) has his fingers in some hellish kind of black pie, a color that takes over his desk as he dozes off when he lets the distinction between his waking and unconscious self slip, we might say, when the reality of sleep threatens the security of being awake. +There is, as the imagery in the poster of the 'Whole Mind Collective' that motivates Mark to bunk off and follow up on Petey's enigmatic red letter suggests, a real revolution of sorts brewing beneath the surface of a fantasy of symbolic control. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-3.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-3.typ new file mode 100644 index 0000000..8c1f032 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/severance-ep-3.typ @@ -0,0 +1,197 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 3 with images + +#import "index.typ": template +#show: template + +#set document(title: [In Perpetuity - #emph[Severance] [s1/e3]]) + +#title() + +#image("img/severance-s1e3-shot1.png") + += We need to talk about Ms. Cobel +As we noted in #link("./severance-ep-1.typ")[analysis of S1E1], she typically storms the screen with an icy blue, a temper (the significance of this word we shall unpack shortly) that seeks to quell the fiery red that flickers in and out of the consciousness of workers on the severed floor. +The ominous ending to that first episode intimated that, while her wintery business has its office underground, it also warrants her prying into Mark's outie's personal life in Baird Creek's subsidized Lumon housing. +Indeed, it seems that Miss Cobel lives in Mark's housing complex, too. +From the state of her fridge, though, which we see in the foreground of a shot that implies surreptitious surveillance at work in her intimate space-- a sense that has already been produced in Mark's home with objects littered in the frame's foreground-- it doesn't appear that she spends very much time making a home there. +(Not too unlike Mark, perhaps.) + +Ms. Cobel is a kaleidoscopic vector of strange femininity in the show. +She is at once old widow next door, a girl-boss superior on the severed floor, and a little girl prone to tantrums. +As Mrs. Selvig, the hare-brained widow next door, she offers Mark unwanted company and cruddy cookies. +Yet we know by now that this is apparanetly a ruse, a senile disguise through which the conniving Harmony Cobel can keep an eye on her employee, Mark, beyond the bounds of his time at work. +At Baird Creek, she is a middle-aged executive in the clothing of an older and less cognitively composed character. + +But even if Ms. Cobel is the 'real' Mrs. Selvig, there is still something anile about her character. +She can be both comandeering and childish, as we see in her encounter with (innie) Mark S when he arrives unannounced at her office to request a kind of permission to take Hellie to the perpetuity wing in S1E3. +Commandering, because she accosts Mark with bureaucratic demands in her role as his boss (#quote[And have you filled +out a common-reservation slip?]). +Childish, because she literally throws a mug at him out of a petty frustration that is unbefitting of a mature manager. +Cobel rationalizes her childish temper as follows: + +#quote(block: true)[ + What I just did was something I knew that you could handle and grow from. + It was very painful for me. + I hope that you'll let it help you. +] + +This outburst locates something undecided within Ms. Cobel, a moment in relation to Mark where she lets her personal anger supersede her role as his manager. +This mug-throwing episode demonstrates that Cobel, too, is capable of breaking character as head of the severed floor and allowing some other aspect of her self to seep in, despite the pretense of a calm composure. +The thrown-mug, in other words, is the wish fulfillment heralded by Cobel's stunningly funny, inappropriate remark to Hellie during her orientation in S1E1; #quote[I've wanted to pummel Mark myself, but I am his employer.] +Even Cobel, who is supposed to be more in charge of herself than the MDR employees who are her inferiors-- her breaking into Mark's house while he isn't there implies is that she is unsevered, and thus more 'responsible'-- harbours desires that exceed and contradict the prescribed role she is supposed to play. + +The image of Cobel above confirms her as childish in some respects. +Notice that here, at 'home', she wears her hair in pigtails rather than loosely around her shoulders. +But it also paints her as a scopophilic and overbearing #emph[mother]. +Whatever she is doing creating excuses to talk to Mark's outie as Mrs. Selvig, it becomes clear in this episode that there is a convoluted kind of care at stake in her creepy and overcurious work. +Peering at him as he wanders up from the basement (Cobel doesn't seem to know that Petey is also down there at this point, though her break-in later in the episode suggests that she suspects something is awry), she murmurs to herself, #quote[Oh, Mark. Are you all right?] + +This is a strange exhibition of affection, coming from the same woman who will throw a mug at Markfor his failure to #quote[get MDR to its numbers] as department chief, who knowingly subjects him to the break room-- which we observe on screen for the first time later in the episode-- and who steals the book left by his brother-in-law as a gift at his doorstep. +Despite these mistreatments, Cobel does still seem to hold some perverted penchant for and attachment to Mark. +As HaxDogma notes in #link("https://www.youtube.com/watch?v=JAhhVnevSm4")[his review of this episode], it is hard to see Mark's promotion to department chief after Petey disappears as anything other than a nepotistic appointment, given that Irving is clearly the more experienced refiner in a number of respects (orientation procedure, group photo protocol, number of years spent on the severed floor, to name a few). +Cobel's overinquisitive manner on display in this episode is perhap best described as motherly, even as she is certainly not a paradigmatically #emph[good] mother. + +There is also something undoubtedly sexual about Cobel's relationship to Mark. +Her lingering at the door in S1E2 waiting to be invited in, her awkward and suggestive mention of her late husband's building an apartment in the back of their abode in heaven #quote[in case I found a new man before I got there], her creating an excuse to talk to him by pretending to de-ice her stoop; and, naturally, her peeping at him through the window. +She is either a stalker by-the-book, or (more charitably) a lonely woman who is searching for some missing satisfaction. +Most likely, she is an inextricable concoction of the two. +Cobel wants to have Mark's cake and eat it too; to be at once his mother, his corporate superior, and (we can't help but suspect) his lover. +Like many put in positions of power, she has trouble setting her more inapproriate desires aside so as to simply 'do her job'. + += Primal father figures +Cobel's mother energy is arguably muted and mixed up in her #link("https://en.wikipedia.org/wiki/Sphinx#Riddle_of_the_Sphinx")[Sphinxesque] triplicity. +But the father energy on display in this episode is, by contrast, loudly and proudly pronounced in at least three different figures: Petey, Irving, and, of course, Kier Eagan. +#footnote[ + There is foreshadowing, too, of a fourth father figure in Rickon, Mark's brother-in-law. + While reading his confiscated book, Milchick quietly remarks to himself a thought that will become an important refrain for many other characters with respect to Rickon later in the season: #quote[This is… Jesus.] +] +Before tackling these fathers one by one, it is instructive to straightforwardly and schematically lay out the #strong[Oedipus complex], an 'absolute fiction' that nonetheless, Freud claims, depicts something foundational about the graph of the speaking subject, the graph in which we took interest in #link("./severance-ep-1.typ")[our analysis of S1E1]. + +The Oedipus complex is so-named because it takes its architecture from the figure of Oedipus as he appears in the ancient Greek playwright #link("https://www.cliffsnotes.com/literature/o/the-oedipus-trilogy/about-the-oedipus-trilogy")[Sophocles' trilogy], which consists of the plays #emph[Oedipus Rex], #emph[Oedipus at Colonus], and #emph[Antigone]. +(Oedipus' tragic tale is drawn from a mythology that predates these plays, but the story is nonetheless usually traced to its Sophoclean production.) +Oedipus is well-known to students of psychoanalysis because of Freud's making him into a #link("https://nosubject.com/Oedipus_complex")[complex], which is generally (mis)understood as 'every person wants to kill their father and fuck their mother'. +Famously, Oedipus killed his father-- at a crossroads, thinking he was simply a threatening stranger at the time-- and married his mother-- not understanding that relation in the moment of the act, either. + +Jacques Lacan rendered the Oedipus complex more philosophically significant than this overblown and crude Freudian telling. +For Lacan, the Oedipus complex designates an abstract account of how desire is produced by the speaking subject in relation to the formative figures with which it is in relation. +As he notes in one of his 1938 text, #emph[The Family Complexes]: + +#quote(block: true)[ +our criticism since Freud presents this psychological entity [the Oedipus complex] as #emph[the specific form of the human family] and subordinates all social variations of the family to it. @lacanFamilyComplexesFormation2002[p.35] +] + +The Oedipus complex is not so much a diagnosis of a particular perversion that is presumed universal, in the sense that everyone #emph[consciously] suffers by repressing these secret dual desires to kill (my father) and to fuck (my mother). +It is rather an important part of how he architects a philosophy of the subject's relation to itself (and others) by way of a #quote[triangular conflict] @lacanFamilyComplexesFormation2002[p.41] between three figures: one's self, the Mother, and the Father. + +The Mother is the subject's first known object that is seen as separable from one's sense of self. +We can imagine this through the process of weaning, of a mother teaching her baby that sustenance ought to be sought in solid foods rather than directly from her teat. +Originally, a baby does not have a firm enough sense of itself to recognize that the Mother's teat is separated from its own body. +When it wants nourishment, it cries, and a breast brimming with milk appears (assuming a good mother, here). +The breast seems almost part and parcel of the baby, from its perspective, as what reason does it have to think otherwise? +(We are assuming here that the separation between a baby's sense of its own body and the world is not ingrained at birth, but rather learned, acculturated.) +It is only when the baby's crying stops precipitating a breast that it should start to doubt this part of itself, to think that perhaps my Mother's breast is not part of #emph[me] as subject but rather its own kind of thing, a separate object. +Thus the Mother is, in this developmental sense, the subject's first #emph[proper] object. +The Mother (and her breast), the baby subject thinks, is both mine and not mine, as though there is some #emph[relation] that my Mother has to me, she is not (quite) the same as me. + +The Father, on the other hand, incorporates (into) the baby subject's sense of self differently. +It is not considered, as the Mother is, a part of the subject that was at some point taken away, but rather represents the source of that action of taking away. +If the Mother #emph[ought] (in the terms of the baby subject's nascent ethics) to be a part of me, the Father is the force and figure responsible for taking her away. +This stature of the Father is better understood, perhaps, with reference to the myth of the #strong[Primal Father], which Lacan reinterprets from its presentation in Freud as originally depicted in the fourth and final chapter of #emph[Totem and Taboo] @freudTotemTabooResemblances1919. +Like the Oedipus complex, the myth of the Primal Father is a narrativization that helps to understand the structure of the subject. +Suppose a primal horde, Freud offers, at the helm of which exists a Primal Father who monopolizes all women. +All women in the horde, in other words, are sexually subject to this single male; no other male gets to enjoy anything of them. +A band of brothers, resentful of the Father's monopoly on enjoyment, conspire to escape the ban on sexual enjoyment through a plot to murder him. +#footnote[ + There has been much written on Freud's mythos of the Primal Father. + For a relatively recent use of the concept that serves as a reasonable introduction to Lacan's reading of #emph[Totem and Taboo], see @mcgowanDistributionEnjoyment2021. +] +They do so through what could be called an original jealousy, a feeling that the Father is enjoying in a way that is prohibited (by virtue of the Father's taboo) for each of them. + +Freud offers this as an #quote[historic explanation… [of] the origin of incest] @freudTotemTabooResemblances1919[p.207], as the Primal Father's taboo on enjoyment is what, Freud suggests, drives exogamy, wherein each of the band of brothers leaves that original tribe to start their own in which they can (finally) enjoy the women for themselves. +That this is an historic explanation does not mean that Freud believes that it represents an actual state of affairs in some distant past. +Indeed, he states the opposite, that #quote[primal state of society has nowhere been observed.] @freudTotemTabooResemblances1919[p.233] +The parable of the Primal Father is historic rather in the sense that narrates to us an important aspect of the structure of the subject, much like Oedipus' tragedy. + += Daddy issues at work + +Okay: we now return from this Freudian digression to the stuff of #emph[Severance]. +What bearing do the Oedipus complex and the myth of the Primal Father have on the structure of the subject on display in the show? +Let's go now to the scene in S1E3 at the crossroads, where MDR runs into two employees in Optics and Design (O&D). + +#image("img/severance-s1e3-shot2.png") + +The composition of this shot puts the reflective axis down the center, and the encounter is suggestively Oedipean in its structure (at a crossroads, unknowing of the Other at play). +Note that Irving is compositionally mirrored by Burt, played by Christopher Walken, and we will explore this suggestive symmetry in detail in later episodes. +The two departments (MDR and O&D) know #emph[of] each other, we surmise from the dialogue that follows. +But Irving isn't supposed to know Burt by name, as he accidentally happened upon him in S1E2 on the way to a Wellness session. +(Burt was coming #emph[from] his Wellness session.) + +While Irving greets Burt on the back of this previous encounter with gentle and flirtatious warmth, Dylan's hostility towards O&D is clear. +In place of the camaraderie that one might have hoped for between the two factions given their shared plight as severed workers, there appears to be an enmity built on a mythology (what Irving calls an #quote[absolute fiction]) of otherness: + +#quote(block: true)[ + Kier sorted the departments by virtue. + Macrodats are clever and true, while O&D's more cruelty-centered…. + O&D tried a violent coup on the others decades ago, and that's why they reduced them down to two. + And that's why they keep us all so far apart now. +] + +Kier is evidently the Primal Father of the severed floor, responsible for instituting the symbolic system of rules, regulations, and affects in the various 'bands of brothers' which reside there. +The tour of the perfect replica of Kier's house later in the episode reinforces his architectural status as Primal Father. +Irving chides Mark for his lack of reverence in deigning to turn the tour of the Perpetuity Wing into Eagan Bingo, and is aghast when he almost happens to #quote[bed sit] on the facsimile in his duplicate chambers. +(Thou shall not lie in Kier Eagan's bed.) +Kier and the lineage of Eagans more generally constitute the #link("https://nosubject.com/Law_of_the_father")[law of the father], the signifier of authority that keeps the severed floor's social order intact, the symbolic source from which both rules and the forbidden temptations of their being broken, taboos, sprout. Irving fosters this authority during the tour, standing in for the absent caregivers, existential (Kier, the Eagans) and material (Cobel and Milchick as superintendents who seem to be letting the kids take care of themselves for a short period). + +Another paternal authority whose absence has haunted and structured Mark since the show's opening is Petey, the man whose shoes he stepped into as MDR's department chief. +As per his exchange to Cobel in the mug-throwing scene, Mark lionizes Petey as a tone-setter, often acting through an ethics refracted by the subordinate conjunctive, 'if Petey were here', or the preface 'Petey used to say'. +Mark's innie is steered more by an imagined sense of what Petey would do, rather than what Kier would. + +Thus while it is Cobel who is explicitly in charge, the spectral presence of these father figures-- Kier, Petey, Irving-- correlatively structures the subject on the severed floor. +There is, in other words, an Oedipal triangular conflict at work in relation the ethical imperative of a severed worker. +The four members of MDR, as orientations to the structure of this subject, suffer different relationships to the positions of Mother and Father. +Mark S is a momma's boy, sired more by Petey's radical rejection of company policy than by Kier. +Dylan, though impertinent to the minutiae in the structure of Law at times, is ultimately his Father's son, acquiring satisfaction by accumulating accolades, and apparently driven by the impending idea of another finger trap or a waffle party. +Irving seems at this point the most mature of the children, looking reverentailly to Kier. +Yet recall that he has been chided by Milchick already for falling asleep on the job, so not all is perfect in paradise. +Hellie has no time for Cobel's authority, yet we will see in due course that her relationship with a Father is a deep lineament in her personality, too. + += Taming tempers +The count of four in the members of MDR mirrors the exact amount of tempers that we learn about from Kier Eagan's wax simulacrum speaking during the tour of the Perpetuity Wing. +These tempers are crucial as coordinates of the Eaganic attempt to coherently quantify the subject, and Kier's pronouncement is deeply significant for our investigation of the subject's distorted structure on the severed floor: + +#quote(block: true)[ + I know that death is near upon me, because people have begun to ask what I see as my life's great achievement. + They wish to know how they should remember me as I rot. + In my life, I have identified four components, which I call tempers, from which are derived every human soul. + #strong[Woe. Frolic. Dread. Malice]. + Each man's character is defined by the precise ratio that resides in him. + I walked into the cave of my own mind, and there I tamed them. + Should you tame the tempers as I did mine, then the world shall become but your appendage. + It is this great and consecrated power that I hope to pass on to all of you, my children. +] + +If there was any doubt that Kier Eagan embodies the Freudian Primal Father, the foundational component of absolute fiction on which the edifice of Law (the rules and taboos by which a subject is bound to abide) is constructed, the quotation above should put it to bed. +Kier's 'philosophy' seeks to conquer death by quantifying life, sorting its myriadic nature into a #quote[precise ratio] of character that can be counted (completely, it seems) in four distinct tempers. +Indeed, we saw the pictorial representation of this taming in s1e2, in the scene where Irving meets Burt: + +#image("img/severance-s1e3-tamingtempers.png") + +In the post-Platonic cave of his own mind, Kier is the master of his passions. +He admits no unconscious contours that sneak up on him unbeknowst in Freudian slips of the tongue or unwanted symptoms. +Indeed, the Eaganesque fantasy of the subject is one in which the necessary excess of language that psychoanalysis discovered does not exist. +Words are detected (via sensors in the elevator, say), controlled, managed. +Any psychoanalytic excess is, in Kier's project of a precisely rationalized subject, beaten out of language. +Excess meaning is 'tamed' as if it were a wild animal by a clear-headed, upstanding, divinely radiant visonary. +(As we will see, the position of primal power that Kier occupies here is sexually overbearing, too, as we might suspect from the Freudian analogy.) + +This episode ends with two scenes depicting the dark and bloody underside of Kier's waxen vision of the precisely quantified human subject. +The first is Helly's harrowing experience in the break room, a space where the unruly distance between words as they are uttered and the meaning they convey is thought to be stamped out, suffocated by the drudgery of debilitating repetition. +A subject will not exceed its authorized symbolization, the break room seems to want to claim. +The worker's unconscious will be tamed and ultimately made beholden to a regime of conscious rationality. +The second, and the closing scene of the epsiode, is Petey's psychotic demise at the convenience store, where he yells at wit's end: #quote[I need tokens so I can eat!] +Ravaged by the failure of his complete quantification inside Lumon, Petey seems no longer to have a firm footing in either his innie's or outie's reality. +Mark looks on from a distance as he collapses outside the store, escorted by police, attempting (it seems) to account for his disintegration. + +#bibliography("./references.bib", style: "chicago-author-date") diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/writing-in-typst.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/writing-in-typst.typ new file mode 100644 index 0000000..1861235 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/content/writing-in-typst.typ @@ -0,0 +1,132 @@ +// @rheo:test +// @rheo:formats html +// @rheo:description Blog post with HTML video elements and custom functions + +#let video(path, width: "auto", height: "auto", controls: "true", autoplay: "false", loop: "false") = { + html.elem("video", attrs: ( + src: path, + width: width, + height: height, + controls: controls, + autoplay: autoplay, + loop: loop, + style: "max-width: 100%", + )) +} + += Writing in Typst | Hacking on Neovim with Claude +== What is a 'good' writing system? +I have been #link("https://www.ohrg.org/devonthink-part-i")[incrementally] #link("https://www.ohrg.org/devonthink-part-ii")[hacking] #link("https://www.ohrg.org/devonthink-part-iii")[on] my writing environment for some time now, since at least 2013 when I started seriously using computers in undergrad. +A couple of years ago, I migrated to Orgmode as the best markup syntax for my needs, and #link("https://www.ohrg.org/writing-setup")[wrote aa post about how Emacs and Orgmode serviced my writing needs]. + +Here's a summary of that post and the core tenets of what I consider an acceptable writing environment, parsed out over the five or so years I've been experimenting with one through grad school: + ++ *Flexible, powerful and distraction-free*. + In short, this means that the environment needs to be an extension to a modal editor in the terminal. + I started using a #link("https://carlosbecker.com/posts/ed/")[modal text editor] around 2018, and use a range of ergonomic keyboards in #link("https://www.ohrg.org/cycling-typing")[funky ways] that make using a mouse undesirable in most cases. + (The web browser is the one environment where I still get some mileage out of a mouse. + I do a lot with keyboard shorcuts via #link("https://vimium.github.io/")[Vimium], but there are still some contexts where it's just quicker or more comfortable to use a mouse.) + One of the main reasons that I settled on Orgmode rather than, say, Markdown at the time was because of its #link("https://orgmode.org/manual/Citations.html")[more standardized bibliographic management]. + ++ *Non-proprietary and sane markup format*. + Microsoft Word documents and Google Docs are great for a lot of things, but I refuse to rely on either of them as a primary format for all of the writing I do, as their formats are to hard to parse (to write custom software for) and bound to Microsoft's and Google's ecosystems respectively. + The ability to run Unix-style comands on a simple markup format from a terminal to search and replace, for example, is an essential. + Writing documents in a plain-text markup language also gives me the safety of knowing that, if it really came down to it, I could write my own parser and compilers. + My writing archive shouldn't strictly rely on some company's infrastructure to host, search, or otherwise make use of the thought it contains. + Using such a format also means that cross-platform editing is made simpler and possible. + (I run linux mostly, but still regrettably use Android as my phone's operating system.) + ++ *Multi-format export*. + #link("https://willcrichton.net/notes/portable-epubs/")[Most of the world's documents are still PDF]. + There's no getting away from needing to export writing as PDF in many cases-- for e-readers like #link("https://www.ohrg.org/using-two-remarkables")[the reMarkable that I use], or for submission to conferences. + But we increasingly read writing on a web page of some sort, and so I also need a workflow to export fully functional documents to HTML and CSS, too. + Other formats that are interesting if not essential include some kind of presentation file (PowerPoint, or better: just a website that has slideshow-like interactions), Markdown for rich formatting to copy somewhere, and plain text. + +I have up until very recently used Orgmode as my markup language of choice, exported them to PDF with exported them to PDF with #link("https://www.latex-project.org/")[latex], and exported them to HTML with #link("https://pandoc.org/")[pandoc]. +But I am very attached to the Neovim ecosystem for my code editing and writing, and so it was clunky to open up an Emacs installation (that I barely understood) exclusively to edit Orgmode. +So I switched to editing Orgmode in Neovim along with everything else, #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[using plugins] and #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[custom functions] to get towards the writing experience that I wanted. + +This has actually worked surprisingly well, but it has some sharp edges. +One of the more significant ones is that any time I want to produce anything more complicated than basic, formatted text with citations and footnotes-- for all of which pandoc transformations produce reasonable output in both HTML and PDF-- I need to start embedding LaTeX into Orgmode, and deal with the LaTeX toolchain / dependency management in order to compile a PDF. +Similarly, if I want to produce an interactive HTML document, I need to embed the source code directly in Orgmode and ensure that the export process handles dependencies and the like appropriately. + +Some of this is unavoidable. +If I want to run custom Javascript in a website that is well beyond the expressive capacities of a markup language, at some point I just want to be able to write Javascript. +But what I found frustrating about my Orgmode / LaTeX / HTML workflow is that there wasn't any reasonable way to work towards extending the markup language in _some_ ways, unless I was willing to start developing my own bespoke flavor of Orgmode plus plus. +I also don't particularly like wrestling with the LaTeX ecosystem, because-- and this is hardly controversial to say-- #link("https://tex.stackexchange.com/questions/222500/why-is-latex-so-complicated")[LaTeX has a lot of bloat]. +What I wanted was a more _extensible_ system which had saner defaults. + +== Enter: Typst + +A few months ago, I started seriously considering #link("https://typst.app/")[typst] as a potential replacement for LaTeX. +At the very least, I thought, it would be more fun to wrestle with a modern ecosystem when struggling to produce some custom table or figure in my output PDF, as typst has a #link("https://typst.app/docs/reference/layout/")[layout system] that uses terms that are a lot more intuitive to me than the black magic of laying out LaTeX documents. + +It just so happened, however, that I started to follow typst development more closely at a time when the final touches to the #link("https://github.com/typst/typst/issues/5512")[basic foundations of HTML export], such as footnotes and bibliography, were just about to be added to the upstream. +So I made #link("https://github.com/typst/typst/pulls?q=is%3Apr+author%3Abreezykermo+is%3Aclosed")[a few contributions] to spirit it along, and started more serious experimentation using typst as a unified way to produce _both_ PDF and HTML in my writing environment. +Pandoc #link("https://pandoc.org/MANUAL.html#typst")[can convert to and from typst], so I originally intended to keep writing documents in Orgmode and then transiently convert them to typst in order to produce PDF and HTML both. +But I quickly found that the typst syntax natively accommodates all of the features that I make use of regularly in Orgmode such as citations, footnotes, headings, links and text decoration-- and then some. + +So why not write my blogs, papers, and documents directly in typst? +I considered the critical features of my Neovim / Orgmode writing environment that I didn't want to abandon: + ++ *Shortcuts for markup*. + The #link("https://github.com/nvim-orgmode/orgmode")[nvim-orgmode plugin] makes writing Orgmode in Neovim pleasurable, providing shortcuts to insert a link and basic text decoration while composing. ++ *Citation and link picking*. + Though I've gone without it for a few months for reasons that are immaterial here, I used to have a shortcut to bring up a fuzzy finder for all of my bibliography entries to easily insert a citation. + The same fuzzy finder would make it easy to link to local files (in a website, for example, to link to other posts). ++ *Document folding*. + The ability to fold away all of the text beneath a heading is very useful when navigating larger documents, as it helps me to compartmentalize writing tasks and organize longer documents such as a dissertation chapter. ++ *Export shortcuts*. + I have customized my Neovim editor so that I can easily export the active Orgmode document (through the pandoc and LaTeX processes described above). + Personally, I don't feel that I need a real-time live preview of the document as I type, as I generally just want to check that it looks reasonable at certain junctures in the writing process, rather than continuously. + +The one other features of Orgmode that I have come to rely on heavily is its #link("https://orgmode.org/manual/TODO-Basics.html")[TODO functionality]. +I typically only use this in notes related to projects or tasks more generally, however, and not in documents that are intended for publication such as a paper or blog post. + +== Enter: Claude Code +At this point in the past of a new writing technology's prospecting, I would go searching for a Neovim plugin for typst and hope that it provides features that satisfy a majority of these requirements. +I've spent a fair bit of time #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua")[tinkering with my init.lua], the entrypoint for customizing Neovim, but I've never had the time nor interest to sit down and write a plugin from scratch. + +LLMs, of course, are at time of writing taking the coding world by storm. +I have started moderately relying on #link("https://github.com/anthropics/claude-code")[Claude Code] when writing some-- though certainly not all-- kinds of code. +As is well-known by now, Claude is especially good at scaffolding hacky scripts or modules from scratch, when no large codebase or domain-specific knowledge needs to be kept in context. +A Neovim plugin, I realized this morning, is a pretty ideal domain for LLM-assisted coding. +The 'codebase' is often just a single configuration file, and the domain-specific knowledge is the Neovim editor itself, a well-documented and expansively customized software for which there are many examples on Reddit.#footnote[It's impossible to mention LLM coding at this time without adding some sort of disclaimer that, no, I don't think AGI is around the corner, and yes, I do expect both programming languages and language writ large to remain 'a thing' in the foreseeable future. LLMs are an incredibly powerful tool to write and analyze code and text, but the purpose of code and text-- as a medium of symbolic communication amongst social beings-- has not been rendered valueless since ChatGPT became publically available. If anything, the value of adeptly and adroitly handling written language has taken deeper root. For my preliminary thoughts on why we are so keen to imagine that computers will supplant the usefulness of the human, I refer the reader to #link("https://caiml.org/dighum/announcements/digital-humanism-salon-capital-and-the-computer-by-lachlan-kermode-2024-06-24/")[this talk I gave in 2024].] + +So I fired up Claude Code earlier this afternoon, and-- fast-forward an hour or two-- I have a fully functional writing environment for Typst that essentially has feature-parity with my Orgmode environment. +Moreover, my #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim")[Neovim config] is now much more comprehensibly modularized; and I have a tried-and-tested method for extending it without needing to spend days learning the ins-and-outs of Neovim's API; and #link("https://github.com/breezykermo/nixos/commit/67cdbbae0dd77db766289b7f6eb278091ab937dd")[some bugbears in my NixOS config were eliminated] while I was at it. +(If that last bit means nothing to you, count yourself lucky!) + +== My new writing environment +I use #link("https://tree-sitter.github.io/tree-sitter/")[treesitter] for syntax highlighting, and Typst already looks pretty good with it. +I get function completion #link("https://github.com/breezykermo/nixos/blob/main/home-manager/server/neovim/lua/plugins/lsp.lua#L17-L24")[by integrating an LSP for the format], for which I'm using #link("https://github.com/Myriad-Dreamin/tinymist")[tinymist]. + +As I noted above, I haven't had dynamic link or citation insertion for some time. +It was one of the features that got lost in my move from writing Orgmode in Emacs to writing it in Neovim. +I use #link("https://github.com/nvim-telescope/telescope.nvim")[telescope.nvim] for general search and file-picking when coding in Neovim, and I figured that I could use a customized pop-up to dynamically pick available citations from the relevant #link("https://www.overleaf.com/learn/latex/Bibliography_management_with_bibtex")[BibTeX] file, too. +After a few minutes of #link("https://simonwillison.net/2025/Oct/7/vibe-engineering/")[vibe-engineering], I have the following: + +#video("../img/typst-links-citations-demo.mp4") + +When I am writing in Typst, and I want to bring in a reference, I can open a panel. +Note that the search is full-text, not just using the reference ID. +I also have a shortcut to specify which bib file to use through the `#bibliography` function in Typst. +I can insert links in the same way as citations, both references files relative to the current one (blog posts on the same site), and external links. +Both the citation and link insertion work either by highlighting text and annotating it, or to insert new links/citations. +I also have a similar shortcut to add footnotes. + +This is pretty functional now for generic writing! + +== Future work +Typst isn't ideal for producing fully-featured websites currently, as HTML export is experimental. +Even when it becomes better supported, the project is-- understandably, given its priority supporting PDF-- taking a #link("https://github.com/typst/typst/issues/5512")[relatively conservative approach] to HTML generation. +Anything that doesn't have a robust analog in a PDF document, such as videos and hover panels, will have to be 'embedded' in Typst with HTML/CSS/JS, rather than being written in Typst syntax. +The current experience isn't much worse than Orgmode with Pandoc, though, and the Typst roadmap promises that it will become much better in the relatively short-term future. + +There is a longstanding issue that I've had with links in Orgmode that I haven't yet tackled with Typst. +When I'm writing, I like hyperlinked text to appear as it will in the final document, i.e. without the underlying URL on display. +When editing any particular line, though, it's better that all of the links are 'expanded' to their full source syntax (`#link("...")[...]`) so that its feasible to edit the markup without requiring any fancy shortcuts. +The effective shortening of lines that occurs when hiding these URLS results in different Neovim line-wrapping requirements, with which the Orgmode plugin I have been using does a bad job, giving ugly linebreaks in documents with long links. +This link presentation will likely be the next feature I add to my Neovim Typst plugin. + +I'll add to the capabilities in #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim/lua/typst")[my Neovim config files], and might eventually release a separate plugin if the features become significant/mature enough. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/readme.org b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/readme.org new file mode 100644 index 0000000..7c45330 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/readme.org @@ -0,0 +1,3 @@ +* Screening the Subject: Severance + +A port of the blog site: [[https://screening-the-subject.ohrg.org/]]. diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/rheo.toml b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/rheo.toml new file mode 100644 index 0000000..80bc1ec --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/blog_site/rheo.toml @@ -0,0 +1,19 @@ +version = "0.2.0" + +# Relative to the rheo folder, if you specify a `oontent_dir`, +# all of the `exclude` patterns become relative to this folder. +content_dir = "content" + +# Default formats to compile (if not specified via CLI flags) +# Options: "pdf", "html", "epub" +# Default: ["pdf", "html"] +# formats = ["html", "pdf"] + +[pdf.spine] +merge = true +vertebrae = ["severance-*.typ"] +title = "Screening the Subject | Severance" + +[epub.spine] +vertebrae = ["severance-*.typ"] +title = "Screening the Subject | Severance" diff --git a/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/cover-letter.typ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/cover-letter.typ new file mode 100644 index 0000000..8874ee9 --- /dev/null +++ b/crates/tests/store/_full_stop_full_stop_slash_full_stop_full_stop_slashexamples_slashcover_minusletter_full_stoptyp/cover-letter.typ @@ -0,0 +1,62 @@ +// @rheo:test +// @rheo:formats pdf +// @rheo:description Job application cover letter with custom formatting + +#import "@preview/aspirationally:0.1.0": aspirationally + +#let role = [Tenure Track Assistant Professor in Typographic Theory] +#let department = [the Gutenberg School of Literary Arts] + +#show: aspirationally( + name: [Laurenz Typesetter], + title: [Cover Letter], + current-department: [Department of Literary Studies], + has-references: false, +)[ + Dear Search Committee, + + I am writing to apply for the position of #role in #department. + I am a PhD Candidate in Literary Studies with an accompanying certificate in Typography and Book Design at Typst University, with research and teaching experience spanning both literary criticism and the material cultures of the written page. + As an interdisciplinary scholar trained in both textual analysis and typographic practice, I publish, teach, and collaborate with researchers across literary studies, design history, and media studies where possible. + I aspire to integrate the best of both in my work through a commitment to rigorous close reading, an open ethos of collaborative making, and the acknowledgement that typographic systems are not neutral vessels for meaning, but constructed through aesthetic, economic, and political choices. + + My dissertation and first book project, The Syntax of Spacing: Typography, Temporality, and the Politics of the PDF, argues that the Portable Document Format represents a crucial but undertheorized site for understanding how literature persists and transforms in the digital age. + Rather than treating the PDF as a mere container for pre-existing texts, I demonstrate how the PDF's particular affordances—its fixity, its portability, its standardization—actively constitute literary meaning and shape reading practices in ways that warrant serious critical attention. + The PDF emerges not as a neutral technology but as a material rhetoric with profound implications for how we theorize textuality, authorship, and the circulation of literary works in the twenty-first century. + Drawing on media archaeology, book history, and close analysis of how literary texts are formatted, embedded, and remediated through PDF structures, I argue that typographic decisions made within this format are fundamentally aesthetic and political choices that deserve the same critical apparatus we bring to questions of literary form. + If we are to understand literature in our present moment with any clarity—as I strongly believe we must—then we first need to attend seriously to the typographic and technological substrates through which literary texts reach us, and what these material conditions have to do with questions of access, preservation, and interpretive freedom. + + My second book project builds on the theoretical framework of my dissertation to examine what I call the poetics of the PDF: the distinctive literary and aesthetic possibilities that emerge when authors, publishers, and readers engage deliberately and creatively with typographic and document systems. + Drawing on experience as a Research Fellow in the Book Arts Initiative (2021–present), I analyze case studies where poets, artists, and experimental writers have exploited PDF's technical capacities—layering, interactivity, embedded media, variable fonts—to generate new forms of literary expression. + These works demonstrate that typography is never merely decorative; it is a fundamental mode of literary thinking. + To preserve and advance a vision of typography as intrinsically connected to literary practice—a vision that animated the modernist avant-gardes and persists in experimental literature today—we must reckon with how contemporary publishing economies often obscure or eliminate typographic choice, standardizing text into unmarked formats that appear natural rather than constructed. + A sufficiently critical approach to typography and typeface design—where critical echoes the spirit of critical literary theory—cannot rely solely on surface characteristics such as readability, accessibility, or aesthetic appeal, though these matter. + It must rather engage with the philosophical and historical questions embedded in typographic form: What labor is inscribed in each letterform? Whose voices are amplified or silenced by particular typographic hierarchies? How do fonts carry ideological weight and cultural memory? + + I have presented my work in diverse and interdisciplinary venues such as the Association of Canadian College and University Teachers of English, the Society for the History of Authorship, Reading and Publishing, the International Conference on the History of the Book, the Typography and Graphic Design Symposium at the Royal College of Art, the Digital Humanities Summer Institute, and the Book History and Print Culture Workshop at Oxford, among others. + I have published essays in journals including Literary Modernism Quarterly, Design and Culture, and The Journal of Electronic Publishing examining the formal properties of digital textuality and the role of typographic systems in literary meaning-making. + My curatorial work with the Book Arts Initiative has resulted in exhibitions and digital collections featured at venues including the Grolier Club in New York, the Plantin-Moretus Museum in Antwerp, the British Library, and various university special collections across North America and Europe. + + I would be thrilled to continue my academic career at #department on account of its distinctive commitment to literary theory, textual studies, and the critical examination of media and form. + Since my undergraduate degree in English Literature at Typst University, I have been drawn to research that situates literary texts squarely within their material and technological conditions, recognizing that typography is as much a matter of meaning-making as narrative or rhetoric. + The work of scholars like Ellen Lupton, Matthew Kirschenbaum, and Miranda Mullin—who theorize design, textuality, and media critically—was formative for my intellectual development. + Their insights have informed my role as Teaching Assistant and curriculum developer in advanced seminars on digital textuality, and have shaped how I think about literary pedagogy in relation to material practice. + My work on typography, format, and literary form would greatly benefit from engagement with faculty working in book history and material culture studies, as well as conversations with colleagues in design history and media studies at the university. + + I am especially committed to teaching literary studies in ways that attend to the material and technical substrates of textuality, which I consider an essential dimension of contemporary literary pedagogy. + At Typst University I have taught two original seminars as Instructor of Record: Typography and Literary Form (taught in 2023 and 2024), and The Politics of Editing: From Manuscript to PDF (2024). + Having served as Teaching Assistant in courses ranging from literary theory to digital humanities, I am equipped to design seminars that combine close reading and critical theory with hands-on engagement with typographic and publishing tools—an approach I believe is essential for students to understand how literary meaning is materially constituted. + In addition to my teaching within the university, I organize a reading group on Typography, Theory, and Literary History that includes faculty and graduate students from universities across the region, as well as Typeface Genealogies, a public-facing project that traces the cultural and political histories embedded in font design and typographic systems. + + I have enclosed the requested materials in this dossier, and additional information about my research and public scholarship can be found at #link("https://laurenztypesetter.edu")[laurenztypesetter.edu]. Thank you for your consideration. + + Sincerely, + + #v(5em) + + Laurenz Typesetter#linebreak() + Ph.D. Candidate in Literary Studies#linebreak() + Certificate in Typography and Book Design#linebreak() + Typst University +] + diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/code_examples.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/code_examples.typ new file mode 100644 index 0000000..1b7df43 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/code_examples.typ @@ -0,0 +1,27 @@ += Code Blocks with Links Test + +This document tests that link transformation correctly handles code blocks. + +== Real Links + +Real links should be transformed: #link("./other.typ")[see other page]. + +Multiple links: #link("./intro.typ")[intro] and #link("./conclusion.typ")[conclusion]. + +== Code Examples + +Inline code should preserve links: `#link("./file.typ")[example]`. + +Code block example: +``` +// This link should be preserved: +#link("./other.typ")[other page] +``` + +== Mixed Content + +Real link: #link("./chapter1.typ")[Chapter 1] + +Then code: `#link("./code.typ")[code link]` + +And another real link: #link("./chapter2.typ")[Chapter 2] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/rheo.toml new file mode 100644 index 0000000..6302c40 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/rheo.toml @@ -0,0 +1,4 @@ +version = "0.2.0" + +# Test PDF transformation +formats = ["pdf"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/appendix/notes.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/appendix/notes.typ new file mode 100644 index 0000000..9457824 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/appendix/notes.typ @@ -0,0 +1,13 @@ += Appendix: Notes + +Additional notes and references. + +== Cross References + +Back to #link("../chapters/ch1.typ")[Chapter 1]. + +Return to #link("../intro.typ")[the introduction]. + +== Details + +Testing links from a different subdirectory. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch1.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch1.typ new file mode 100644 index 0000000..b62d95b --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch1.typ @@ -0,0 +1,11 @@ += Chapter 1 + +This is the first chapter. + +== References + +Go back to #link("../intro.typ")[the introduction]. + +Continue to #link("ch2.typ")[Chapter 2] (sibling). + +See #link("../appendix/notes.typ")[the appendix notes] for additional info. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch2.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch2.typ new file mode 100644 index 0000000..0bd8187 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch2.typ @@ -0,0 +1,13 @@ += Chapter 2 + +This is the second chapter. + +== Navigation + +Previous: #link("ch1.typ")[Chapter 1] + +Root: #link("../intro.typ")[Introduction] + +== Content + +Testing cross-directory navigation patterns. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/intro.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/intro.typ new file mode 100644 index 0000000..bdabdac --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/intro.typ @@ -0,0 +1,9 @@ += Introduction + +Welcome to the cross-directory test. + +== Overview + +This document links to #link("chapters/ch1.typ")[Chapter 1]. + +See also #link("chapters/ch2.typ")[Chapter 2] for more details. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/rheo.toml new file mode 100644 index 0000000..61b6bec --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/rheo.toml @@ -0,0 +1,8 @@ +version = "0.2.0" + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Cross Directory Test" +vertebrae = ["intro.typ", "chapters/ch1.typ", "chapters/ch2.typ", "appendix/notes.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/chapter.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/chapter.typ new file mode 100644 index 0000000..236b6df --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/chapter.typ @@ -0,0 +1,5 @@ +#set document(title: [Main Chapter]) + += Chapter 1 + +The main content. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/intro.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/intro.typ new file mode 100644 index 0000000..e917334 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/intro.typ @@ -0,0 +1,5 @@ +#set document(title: [Introduction]) + += Introduction + +Welcome to the book. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/rheo.toml new file mode 100644 index 0000000..c6bc8cb --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/rheo.toml @@ -0,0 +1,5 @@ +version = "0.2.0" + +[epub.spine] +title = "My EPUB Book" +vertebrae = ["intro.typ", "chapter.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/a.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/a.typ new file mode 100644 index 0000000..e01171a --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/a.typ @@ -0,0 +1,5 @@ +#set document(title: "Part A") + += Part A + +This is the first part of the document. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/b.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/b.typ new file mode 100644 index 0000000..7f0dea7 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/b.typ @@ -0,0 +1,5 @@ +#set document(title: "Part B") + += Part B + +This is the second part of the document. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/c.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/c.typ new file mode 100644 index 0000000..3941877 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/c.typ @@ -0,0 +1,5 @@ +#set document(title: "Part C") + += Part C + +This is the third part of the document. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/array_index_error.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/array_index_error.typ new file mode 100644 index 0000000..cb27669 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/array_index_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "array_index_error.typ", "│" +// @rheo:formats pdf +// Test file with array index error + += Array Index Error Test + +// Create a small array +#let items = ("first", "second", "third") + +// Try to access an index that doesn't exist +#items.at(10) + +Some content diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/function_arg_error.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/function_arg_error.typ new file mode 100644 index 0000000..396014b --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/function_arg_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "function_arg_error.typ", "│" +// @rheo:formats pdf +// Test file with function argument error + += Function Argument Error Test + +// Define a function that requires two arguments +#let add_numbers(x, y) = x + y + +// Call it with only one argument (missing required argument) +#add_numbers(5) + +Some content diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/import_error.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/import_error.typ new file mode 100644 index 0000000..ca1820c --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/import_error.typ @@ -0,0 +1,12 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "import_error.typ", "│" +// @rheo:formats pdf +// Test file with import error (missing file) + += Import Error Test + +// Try to include a file that doesn't exist +#include "nonexistent_file.typ" + +Some content diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_field.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_field.typ new file mode 100644 index 0000000..53a0410 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_field.typ @@ -0,0 +1,18 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "invalid_field.typ", "│" +// @rheo:formats pdf +// Test file with invalid field access + += Invalid Field Access Test + +// Create a dictionary and try to access non-existent field +#let person = ( + name: "Alice", + age: 30 +) + +// Try to access a field that doesn't exist +#person.nonexistent_field + +Some content diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_method.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_method.typ new file mode 100644 index 0000000..9b634d3 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_method.typ @@ -0,0 +1,13 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "invalid_method.typ", "│" +// @rheo:formats pdf +// Test file with invalid method call + += Invalid Method Test + +// Try to call a method that doesn't exist on strings +#let text = "hello" +#let result = text.nonexistent_method() + +Some content diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/multiple_errors.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/multiple_errors.typ new file mode 100644 index 0000000..63b4c70 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/multiple_errors.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "multiple_errors.typ", "│" +// @rheo:formats pdf +// Test file with multiple errors + += Multiple Errors Test + +// First error: undefined variable +#let x = undefined_var_one + +// Second error: type mismatch +#let y = 5 + "string" + +// Third error: undefined variable again +The value is: #undefined_var_two diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/rheo.toml new file mode 100644 index 0000000..4b4e51e --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/rheo.toml @@ -0,0 +1,7 @@ +version = "0.2.0" + +# Test project for error formatting validation +formats = ["pdf"] + +[pdf] +# Don't merge - test single file errors diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/syntax_error.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/syntax_error.typ new file mode 100644 index 0000000..4dd311e --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/syntax_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "syntax_error.typ", "│" +// @rheo:formats pdf +// Test file with syntax error (unclosed delimiter) + += Syntax Error Test + +#let items = [ + Item 1, + Item 2, + Item 3 +// Missing closing bracket ] + +Content follows diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/type_error.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/type_error.typ new file mode 100644 index 0000000..01aa6cd --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/type_error.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "type_error.typ", "│" +// @rheo:formats pdf +// Test file with type error +// This should trigger a Typst compilation error + += Type Error Test + +#let x = 5 +#let y = "hello" + +// This will cause a type error: can't add number and string +#let result = x + y + +Content: #result diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/undefined_var.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/undefined_var.typ new file mode 100644 index 0000000..e73bbae --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/undefined_var.typ @@ -0,0 +1,10 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "undefined_var.typ", "undefined_variable", "│" +// @rheo:formats pdf +// Test file with undefined variable error + += Undefined Variable Test + +// This will cause an error: undefined_variable doesn't exist +The value is: #undefined_variable diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/unknown_function.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/unknown_function.typ new file mode 100644 index 0000000..0d3e287 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/error_formatting/unknown_function.typ @@ -0,0 +1,12 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "unknown_function.typ", "│" +// @rheo:formats pdf +// Test file with unknown function call + += Unknown Function Test + +// Call a function that doesn't exist +#nonexistent_function("arg1", "arg2") + +Some content here diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/about.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/about.typ new file mode 100644 index 0000000..1e9f1c4 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/about.typ @@ -0,0 +1,5 @@ +#set document(title: [About]) + += About + +Information about the site. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/index.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/index.typ new file mode 100644 index 0000000..f041468 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/index.typ @@ -0,0 +1,7 @@ +#set document(title: [Home]) + += Welcome + +This is the home page. + +See also: @about diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/rheo.toml new file mode 100644 index 0000000..cad360b --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/html_spine/rheo.toml @@ -0,0 +1,5 @@ +version = "0.2.0" + +[html.spine] +title = "My Website" +vertebrae = ["index.typ", "about.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/chapter-01.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/chapter-01.typ new file mode 100644 index 0000000..90d0f99 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/chapter-01.typ @@ -0,0 +1,5 @@ += Chapter 01 + +This filename contains numbers. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file-name.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file-name.typ new file mode 100644 index 0000000..3fd2139 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file-name.typ @@ -0,0 +1,5 @@ += File with Hyphen + +This filename contains a hyphen. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file_name.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file_name.typ new file mode 100644 index 0000000..b786beb --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file_name.typ @@ -0,0 +1,5 @@ += File with Underscore + +This filename contains an underscore. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/main.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/main.typ new file mode 100644 index 0000000..b557e5f --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/main.typ @@ -0,0 +1,17 @@ += Path Edge Cases Test + +This tests unusual but valid filename patterns. + +== Links to Edge Case Files + +Hyphen: #link("file-name.typ")[file with hyphen] + +Underscore: #link("file_name.typ")[file with underscore] + +Dot in name: #link("version-1.0.typ")[file with dot] + +Number: #link("chapter-01.typ")[file with number] + +== Content + +All these edge cases should transform correctly. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/rheo.toml new file mode 100644 index 0000000..f6d36e5 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/rheo.toml @@ -0,0 +1,8 @@ +version = "0.2.0" + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Path Edge Cases Test" +vertebrae = ["main.typ", "file-name.typ", "file_name.typ", "version-1.0.typ", "chapter-01.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/version-1.0.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/version-1.0.typ new file mode 100644 index 0000000..7fc2556 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/version-1.0.typ @@ -0,0 +1,5 @@ += Version 1.0 + +This filename contains a dot in the name (not just the extension). + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc1.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc1.typ new file mode 100644 index 0000000..69c2f08 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc1.typ @@ -0,0 +1,12 @@ +// @test-formats: pdf,html,epub +// @test-description: Verify AST-based .typ link transformation + += Document 1 + +This is the first document. + +You can navigate to #link("./doc2.typ")[See Doc 2] for more information. + +== Section in Doc 1 + +More content here. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc2.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc2.typ new file mode 100644 index 0000000..5925102 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc2.typ @@ -0,0 +1,9 @@ += Document 2 + +This is the second document. + +Go #link("./doc1.typ")[Back to Doc 1] to see the first document. + +== Another Section + +Additional content in document 2. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/rheo.toml new file mode 100644 index 0000000..d3116e9 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/link_transformation/rheo.toml @@ -0,0 +1,10 @@ +version = "0.2.0" + +[pdf.spine] +merge = true +title = "Link Transformation Test" +vertebrae = ["doc1.typ", "doc2.typ"] + +[epub.spine] +title = "Link Transformation Test" +vertebrae = ["doc1.typ", "doc2.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page1.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page1.typ new file mode 100644 index 0000000..9164d4d --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page1.typ @@ -0,0 +1,11 @@ += Page 1 + +This is the first page. + +See the #link("./page2.typ#intro")[introduction in Page 2] for details. + +Also check #link("./page2.typ#conclusion")[the conclusion]. + +== Section in Page 1 + +More content here. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page2.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page2.typ new file mode 100644 index 0000000..82d874d --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page2.typ @@ -0,0 +1,19 @@ += Page 2 + +This is the second page. + +== Introduction + +This is the introduction section. + +It has some content that the first page links to. + +== Middle Section + +Some middle content. + +== Conclusion + +This is the conclusion section. + +Referenced from page 1. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/rheo.toml new file mode 100644 index 0000000..38e80ac --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/rheo.toml @@ -0,0 +1,12 @@ +version = "0.2.0" + +formats = ["html", "pdf", "epub"] + +[pdf.spine] +merge = true +title = "Links with Fragments Test" +vertebrae = ["page1.typ", "page2.typ"] + +[epub.spine] +title = "Links with Fragments Test" +vertebrae = ["page1.typ", "page2.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/multiple_links_inline.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/multiple_links_inline.typ new file mode 100644 index 0000000..cf1189a --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/multiple_links_inline.typ @@ -0,0 +1,21 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Multiple links per line test + += Multiple Links Test + +== Adjacent Links with Text + +See #link("file1.typ")[File 1] and #link("file2.typ")[File 2] for details. + +== Multiple References in List + +References: #link("a.typ")[A], #link("b.typ")[B], #link("c.typ")[C]. + +== Minimal Separation + +Adjacent links: #link("x.typ")[X]#link("y.typ")[Y] + +== Multiple Links in Sentence + +Check #link("intro.typ")[the introduction], then #link("chapter1.typ")[chapter 1], and finally #link("conclusion.typ")[the conclusion]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter1.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter1.typ new file mode 100644 index 0000000..7b27ae2 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter1.typ @@ -0,0 +1,7 @@ +#set document(title: "Chapter 1") + += Chapter 1 + +This is the first chapter. + +See also: #link("./chapter2.typ")[Chapter 2] for more information. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter2.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter2.typ new file mode 100644 index 0000000..a41be40 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter2.typ @@ -0,0 +1,7 @@ +#set document(title: "Chapter 2") + += Chapter 2 + +This is the second chapter. + +Refer back to #link("./chapter1.typ")[Chapter 1] if needed. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/rheo.toml new file mode 100644 index 0000000..d388837 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_individual/rheo.toml @@ -0,0 +1,3 @@ +version = "0.2.0" + +formats = ["pdf"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter1.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter1.typ new file mode 100644 index 0000000..0241079 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter1.typ @@ -0,0 +1,5 @@ += Chapter 1 + +First chapter content. + +See also #link("./chapter2.typ")[Chapter 2]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter2.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter2.typ new file mode 100644 index 0000000..0cb7bf5 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter2.typ @@ -0,0 +1,7 @@ += Chapter 2 + +Second chapter content. + +Refer back to #link("./chapter1.typ")[Chapter 1]. + +Jump to #link("./conclusion.typ")[Conclusion]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/conclusion.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/conclusion.typ new file mode 100644 index 0000000..28e2cf2 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/conclusion.typ @@ -0,0 +1,3 @@ += Conclusion + +Final thoughts. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/intro.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/intro.typ new file mode 100644 index 0000000..f21fd34 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/intro.typ @@ -0,0 +1,5 @@ += Introduction + +This is the introduction. + +Continue to #link("./chapter1.typ")[Chapter 1]. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/rheo.toml new file mode 100644 index 0000000..716eb45 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" + +[pdf.spine] +merge = true +vertebrae = ["intro.typ", "chapter*.typ", "conclusion.typ"] +title = "Test Merged Document" diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/a.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/a.typ new file mode 100644 index 0000000..371d2ea --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/a.typ @@ -0,0 +1,5 @@ +#set document(title: "doc1") + += A + +The first doc. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/b.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/b.typ new file mode 100644 index 0000000..05b0ba0 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/b.typ @@ -0,0 +1,6 @@ +#set document(title: "B") + += B + +THIS IS A DRAFT, DO NOT RENDER. + diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/c.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/c.typ new file mode 100644 index 0000000..f8931d9 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/c.typ @@ -0,0 +1,5 @@ +#set document(title: "doc2") + += C + +The second doc. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/rheo.toml new file mode 100644 index 0000000..68d5f34 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/rheo.toml @@ -0,0 +1,16 @@ +version = "0.2.0" + +formats = ["pdf", "html"] + +[pdf.spine] +vertebrae = [ + "a.typ", + "c.typ" +] +merge = false + +[html.spine] +vertebrae = [ + "a.typ", + "c.typ" +] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file1.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file1.typ new file mode 100644 index 0000000..6e6e5e5 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file1.typ @@ -0,0 +1,5 @@ +#set document(title: [File 1]) + += Chapter 1 + +This is the first file. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file2.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file2.typ new file mode 100644 index 0000000..4cec706 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file2.typ @@ -0,0 +1,5 @@ +#set document(title: [File 2]) + += Chapter 2 + +This is the second file. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/rheo.toml new file mode 100644 index 0000000..8bf9dab --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" + +[pdf.spine] +title = "Individual PDFs" +vertebrae = ["file*.typ"] +merge = false diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/rheo.toml new file mode 100644 index 0000000..e7d4015 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/rheo.toml @@ -0,0 +1,8 @@ +version = "0.2.0" + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Relative Path Test" +vertebrae = ["root.typ", "subdir/child.typ", "subdir/sibling.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/root.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/root.typ new file mode 100644 index 0000000..4ad9a58 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/root.typ @@ -0,0 +1,13 @@ += Root Document + +This is the root of the test project. + +== Links to Subdirectory + +See #link("subdir/child.typ")[the child document] in the subdir. + +Also check out #link("subdir/sibling.typ")[the sibling]. + +== More Content + +This tests that subdirectory paths transform correctly. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/child.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/child.typ new file mode 100644 index 0000000..ed0437a --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/child.typ @@ -0,0 +1,15 @@ += Child Document + +This document is in a subdirectory. + +== Link to Parent Directory + +Go back to #link("../root.typ")[the root document]. + +== Link to Sibling (Explicit Same Dir) + +See #link("./sibling.typ")[the sibling] in the same directory. + +== Link to Sibling (Implicit Same Dir) + +Also see #link("sibling.typ")[the sibling again] with implicit path. diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/sibling.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/sibling.typ new file mode 100644 index 0000000..b1ad7a1 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/sibling.typ @@ -0,0 +1,15 @@ += Sibling Document + +This is the sibling document in the subdirectory. + +== Link to Sibling + +Go to #link("child.typ")[the child document]. + +== Link to Parent Directory + +Return to #link("../root.typ")[root]. + +== Content + +Testing various relative path patterns. diff --git a/tests/cases/target_function/main.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function/main.typ similarity index 100% rename from tests/cases/target_function/main.typ rename to crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function/main.typ diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function/rheo.toml new file mode 100644 index 0000000..038e6ad --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function/rheo.toml @@ -0,0 +1,3 @@ +version = "0.2.0" + +formats = ["html", "pdf", "epub"] diff --git a/tests/cases/target_function_in_module/lib/format_helper.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/lib/format_helper.typ similarity index 100% rename from tests/cases/target_function_in_module/lib/format_helper.typ rename to crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/lib/format_helper.typ diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/main.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/main.typ new file mode 100644 index 0000000..11ab260 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/main.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:formats html,pdf,epub +// @rheo:description Verifies target() works in imported modules + +#import "lib/format_helper.typ": get_format, format_specific_content + += Target Function in Module + +== Main File +#context [Main: *#target()*] + +== Imported Module +#context [Module returns: *#get_format()*] + +== Module Conditional +#format_specific_content() diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/rheo.toml new file mode 100644 index 0000000..88af797 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" +formats = ["html", "pdf", "epub"] + +[epub.spine] +title = "Target Function in Module" +vertebrae = ["main.typ"] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/main.typ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/main.typ new file mode 100644 index 0000000..1901929 --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/main.typ @@ -0,0 +1,35 @@ +// @rheo:test +// @rheo:formats html,epub +// @rheo:description Tests target() polyfill vs packages using std.target() +// +// This test demonstrates rheo's target() polyfill: +// +// - User code using target() sees "epub" for EPUB output (via polyfill) +// - Universe packages that call std.target() see "html" (the underlying compile target) +// +// Why packages see "html": +// - EPUB compilation uses Typst's HTML export internally +// - Packages like bullseye explicitly call std.target() to get the "real" target +// - This is expected behavior - std.target() returns the underlying format +// +// For package authors: +// - Packages can adopt rheo's pattern to detect rheo output format +// - The pattern: `if "rheo-target" in sys.inputs { sys.inputs.rheo-target } else { target() }` +// - This provides graceful degradation when compiled outside rheo + +#import "@preview/bullseye:0.1.0": on-target + += Target Function in Package + +== Using bullseye package + +// Expected: "html" in both HTML and EPUB modes (bullseye calls std.target()) +#context on-target( + html: [Package sees: *html*], + paged: [Package sees: *paged*], +) + +== Using target() + +// Expected: "html" for HTML, "epub" for EPUB (uses polyfill) +Main file target: #context [*#target()*] diff --git a/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/rheo.toml b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/rheo.toml new file mode 100644 index 0000000..0e3402a --- /dev/null +++ b/crates/tests/store/cases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" +formats = ["html", "epub"] + +[epub.spine] +title = "Target Function in Package" +vertebrae = ["main.typ"] diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot1.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot1.jpg new file mode 100644 index 0000000..15ec7c6 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot1.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot2.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot2.png new file mode 100644 index 0000000..86ef4bd Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot2.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot3.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot3.jpg new file mode 100644 index 0000000..5748da6 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e1-shot3.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-apple-II.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-apple-II.jpg new file mode 100644 index 0000000..34b4c3b Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-apple-II.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-bell-works.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-bell-works.png new file mode 100644 index 0000000..21af54e Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-bell-works.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-commodore-pet.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-commodore-pet.jpg new file mode 100644 index 0000000..a3a14cd Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-commodore-pet.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-ibm-pc.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-ibm-pc.jpg new file mode 100644 index 0000000..5fdd501 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-ibm-pc.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-lumon-logo.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-lumon-logo.png new file mode 100644 index 0000000..ef615ce Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-lumon-logo.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot1.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot1.png new file mode 100644 index 0000000..00d9360 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot1.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot2.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot2.png new file mode 100644 index 0000000..b0084f6 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e2-shot2.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot1.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot1.png new file mode 100644 index 0000000..dfa281e Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot1.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot2.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot2.png new file mode 100644 index 0000000..ba39e37 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-shot2.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-tamingtempers.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-tamingtempers.png new file mode 100644 index 0000000..6eaac35 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/severance-s1e3-tamingtempers.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/typst-links-citations-demo.mp4 b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/typst-links-citations-demo.mp4 new file mode 100644 index 0000000..a29ea41 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/img/typst-links-citations-demo.mp4 differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/index.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/index.typ new file mode 100644 index 0000000..e80f8be --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/index.typ @@ -0,0 +1,37 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Main blog index page with post listings + +#let div(_class: "", ..body) = html.elem("div", attrs: (class: _class), ..body) +#let br() = html.elem("br") +#let hr() = html.elem("hr") +#let ul(_class: "", ..body) = html.elem("ul", attrs: (class: _class), ..body) +#let li(_class: "", ..body) = html.elem("li", attrs: (class: _class), ..body) + +#let template(doc) = { + doc + context if target() == "html" or target() == "epub" { + div[ + #br() + #hr() + #ul[ + #li[#link("./index.typ")[Home]] + #li[#link("https://lachlankermode.com")[Learn more] about me] + #li[#link("https://ohrg.org")[Read other musings]] + ] + ] + } +} + +#show: template + += Screening the subject + +_Screening the subject_ is a blog that analyses content on both the big and small screen in reasonable detail, i.e. episode-by-episode or scene-by-scene. +Contact us at #link("mailto:info@ohrg.org")[info\@ohrg.org] for enquiries. + +// Be alerted of new content by subscribing to the #link("https://screening-the-subject.ohrg.org/feed.xml")[RSS feed]. + +- #link("./severance-ep-1.typ")[Severance, s1/e1] +- #link("./severance-ep-2.typ")[Severance, s1/e2] +- #link("./severance-ep-3.typ")[Severance, s1/e3] diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/references.bib b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/references.bib new file mode 100644 index 0000000..f8543cd --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/references.bib @@ -0,0 +1,30 @@ +@article{freudTotemTabooResemblances1919, + title = {Totem and {{Taboo}}: {{Resemblances Between}} the {{Psychic Lives}} of {{Savages}} and {{Neurotics}}}, + author = {Freud, Sigmund}, + translator = {Brill, A.A}, + year = {1919}, + journal = {Moffat, Yard and Company}, + volume = {50}, + number = {1}, + pages = {94--95}, + publisher = {LWW}, + urldate = {2025-06-05} +} + +@misc{lacanFamilyComplexesFormation2002, + title = {Family {{Complexes}} in the {{Formation}} of the {{Individual}}}, + author = {Lacan, Jacques}, + year = {2002}, + publisher = {Antony Rowe London}, + urldate = {2025-05-22}, + file = {/home/lox/Zotero/storage/HAKMXWZ5/Lacan - 2002 - Family Complexes in the Formation of the Individual.pdf} +} + +@article{mcgowanDistributionEnjoyment2021, + title = {The {{Distribution}} of {{Enjoyment}}}, + author = {McGowan, Todd}, + year = {2021}, + journal = {European Journal of Psychoanalysis}, + volume = {8}, + number = {1} +} diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-1.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-1.typ new file mode 100644 index 0000000..cd00d88 --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-1.typ @@ -0,0 +1,111 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post with images, footnotes, and bibliography + +#import "index.typ": template +#show: template + +#set document(title: [Good news about hell - #emph[Severance] [s1/e1]]) + +#title() + +#image("img/severance-s1e1-shot1.jpg") + +The first thing to notice is the colour palette. +She is dressed in blue, but her hair is chestnut red. +It spills out for the frame of her figure into the table around it, blockaded at its border by chairs and a carpet clad in green, yellow, then green again; then gray. +The establishing shot is a bird's eye view of an unknown woman who is soon revealed to have been put in the board room by someone else's design, who learns about her predicament only by a man's voice that emanates from the little device that rests on the table along with the woman, arranged so that it aims directly at her head. + +This opening image is a graph of the subject's predicament on the severed floor at Lumon. +Blue is the company colour. +Employees are almost invariably dressed in shades of it-- navy, midnight, Prussian, Oxford, cobalt-- and more reliably so as we work our way up the hierarchy. +Red is unruly passion, the tone of tempers that itch to tear off the straitjacket directives, to disregulate the business-as-usual in which there is no obvious place for illicit activities. +Green is the accent of Macro Data Refinement, the division of Lumon in which the show's protagonists are employed. +The device directs a man's voice at a woman's body in an attempt to keep her tempers in check, to ensure her firecraft does not smoke out the staid edifice of personality management, to order her #quote[perceptual chronologies] accordingly. +(Later in the episode, we learn that she almost manages to #quote[break in] on the control room during that opening sequence: the solidity of its enclosure is threatened from the very first.) + +It is instructive to attempt to articulate the dynamics that this graph indexes before we start talking about other scenes in the show. +Graphs are not at one with what they represent, for in the decision to render 'data' in the very act of a representation, we both lose and gain distinction of the dynamics in question. +The voice that opens Helly R up to the world of Lumon's severed floor begins: #quote[Who are you?] +This question is a mistake. +We retroactively learn, in a later scene, that Mark S was in fact supposed to begin with a less interrogative, more perfunctory: #quote[Hi there, you on the table. I wonder if you'd mind taking a brief survey.] +As Irving puts it: #quote[You [Mark S] skipped the preamble]. +Helly R is thrust, by this accident, immediately into questioning not only herself, but also the self-assurance of the voice that interrogates her. +Does this voice in my head [she could be thinking] really know what it is doing? +Or is it just a role of similarly confused actors struggling to stick to a badly written script? + +#link("https://www.youtube.com/watch?v=QIsLXuVeUgM")[This episode-length recap] of the first episode names this graph 'the Helly incident', a poorly executed orientation of Helly's newfound subjectivity that can be blamed at one level on Mark S (for starting with the wrong part of the manual), at another on Mr. Milchick (for misguiding Mark while he was distracted setting up the visual feed), on Ms. Cobel (for giving Mark Petey K's old manual without redacting his obscurely scribbled notes and paper bookmarks), or even on Irving (for neglecting to intervene and clarify how Mark should begin being the more senior refiner in the situation: #quote[Irving will be there to shadow. Just stick to the flowchart and escalate properly depending on dialectics.]). +Wherever to place blame, there is doubtless a misconfiguration that takes place. +Helly's instinctual reaction seems to be to try to kill the voice pointed at her head, rather than to befriend it as Mark states he did (where Petey was Mark). +(Helly will eventually have sex with the source of the voice, rather than murdering or fraternizing with it.) +In this episode, however, Mark (the voice's source) is physically assaulted by Helly, dented in his temple by the same vocalization device that mediated their first communication. + +#image("img/severance-s1e1-shot2.png") + +So this is the Macro Data refiner's situation. +On the one hand, she is affronted with a voice that compels her to abide by the rules and permits her to enjoy some small reliefs (egress from a locked room) if she concedes to it. +On the other, she is always teeming and thus flirting with red, considering escape routes that involve drawing blood, setting off alarms, or removing clothes. + +This unruly red is what Macro Data Refinement's greening procedures are supposed to contain to produce a completely controlled and scripted blot of blue. +Perhaps this is why the glipse of the vacant desks planned for the severed floor's expansion are draped in purple, for that shade of subjectivity would better incorporate the contrasting contours into a unified and taskable tone. +The red that threatens Lumon's corporate, calm, and collected blue (the Lumon logo is a water droplet that suspiciously resembles a camera) is splattered across scenes in the episode. +It is, for example, the envelope that Petey slips Mark at the company-owned restaurant #emph[Pip's] with the suggestion that he should read it if he wants to know #quote[what's going on down there]. +It is the sweater Mark wears to his sister's dinnerless dinner party, punctuated by red place mats (#quote[what a lot of people overlook, I think, is that life is not food]), where the ontological substance of his innie is called into question, and where we learn about the passions he has lost-- the history of World War II, educating, whiskey-- the last of which seems to have given way to an indiscriminate consumption of beer, wine, anything that will drown out the clarity of sober consciousness. +It is the general hue of his sister's house, which consisently wants him to question that placid blue of his company-subsidized housing at Baird Creek Manor. + +This dinner tells us something more about the subject in question in #emph[Severance]. +Just as Helly's outie had alerted us to the basic principle in the video her innie was shown in curiously lo-fi resolution to conclude her innie's orientation-- #quote[perceptual chronologies… surgically split]-- Mark's predicament is comparably explained to him by another more or less ignorant (we can't help but imagine) third party: #quote[One's memories are bifurcated, so when you're at work, you have no recollection of what it is you do there.] +As pretentious as they are, the dinner's guests do seem to be attuned to an important dimension of the meaning of life, which is that it can't #emph[only] be about satiating biological needs such as food. +What each individual 'needs' is a disharmonious melange of needs and demands, openings of desire that emerge not only through a graph of bare necessities-- food, water, shelter-- but also through capricious carapaces that emerge from more ambiguous pinings in the social sphere-- company, care, love. +The real question of Lumon's smooth functioning is whether it will be able to effectively plug up these pinings, the incidental moments at work where one wonders what one is really doing with one's life, whether the company can really manage its employees' unsanctioned thoughts and the way in which those illicit ideas seep into the daily practice of their workerhood. +More on the plasticity of our needs and drives to satisfy them in later posts. + +#image("img/severance-s1e1-shot3.jpg") + +Ms. Cobel, in contrast to Helly's and Mark's doubtful and doubting red, is a stormy and icy blue. +(We must wait until season two to uncover the historical and psychological depth of this colour for Harmony Cobel.) +She is the figure with a body that seems to be the most in charge, of those we meet in this episode. +Though Ms. Cobel is not a master in herself, it seems, for she too is subjected to a disembodied voice-via-device, 'the board', albeit which only appears evidently as an ear so far (#quote[The board won't be contributing to this meeting vocally]). +Cobel is responsible for keeping the severed floor's uncertainty in check, the 'head' that sits atop the variegated limbs of its disobedient body. + +When Cobel reprimands Mark for his derailing of Helly's orientation, she recalls an obscure and theological aspect of her parentage: + +#quote(block: true)[ + You know, my mother was an atheist. + She used to say that there was good news and bad news about hell. + The good news is, hell is just the product of a morbid human imagination. + The bad news is, whatever humans can imagine, they can usually create. +] + +At the close of the episode, just before Mark's senile neighbor Mrs. Selvig (who we have only heard about through Mark's voice thus far, when he is on the phone with her) visually reveals herself to be the same woman as Ms. Cobel, she gives a slightly different account of her heritage: + +#quote(block: true)[ + You know, my mother was a Catholic. + She used to say it takes the saints eight hours to bless a sleeping child. + I hope you aren't rushing the saints. +] + +It's unclear at this point whether Cobel is a severed worker like Mark, or whether there is some other reason for her (strange, almost senseless) duplicity. +Why lie about the religious leanings of one's mother? +Or maybe 'mother' is actually a name for something else, a kind of interim authority that gives synthetic weight to some hearsay, rumor, or idle phrase. +(The other cameo of an ambiguously defined mother in this episode is in question five of Helly's orientation survey: #quote[To the best of your memory, what is or was the color of your mother's eyes?]) +Perhaps it is that, severed or not, atheist or Catholic, Cobel's subjectivity is structured by a comparable split in her perceptual chronologies, whereby some memories (of her mother) get more airtime in her conscious experience of herself than others. + +#emph[Severance] flirts with this idea extensively, that the innie/outie dyad is analagous to the unconscious/conscious experience that we, as subjects, have of ourselves. +Mark's sister Devon hints at the psycho-logical reading of the severed condition in her diagnosis of Mark's morose (outie) predicament as a state of failed therapy in response to mourning for his late wife: #quote[I just feel like forgetting about her for eight hours a day isn't the same thing as healing.] +As with not-mothers and the plasticity of the drive, we will address the psychoanalytic implications here in later posts; but to finish I want to bring our attention to the #emph[imaging of time] at work in just this first episode. + +The fascinating details of failed synchronisation between all the watchfaces we see are enumerated in #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/txxbcm/observation_and_question_regarding_time/")[this Reddit thread]. +Many of the watch hands appear to be stalled, and the crossover from each to the next-- as when Mark Scout switches his wrist watch in preparation for his elevator descent into the workday of innie Mark S-- doesn't match with our experience of the actors on screen. +One of the few things we do know about the severance procedure is that it 'alters perceptual chronologies', and that this messing with a subject's sense of time is thought to + ++ make them more adequate or productive in a certain kind of work (for why else would Lumon go to the necessary lengths to sever some employees) ++ supposes to section off innie memories and experience from outie memories and experience + +So the subject's subjectivity is marked by its sense of time, and Lumon's success (profitability?) hinges in some way on altering their employees' stable sense of it while in the space of the severed floor. + +Mark S's temporal predicament here has been explained by a man whose last name we get by speeding up the saying of his own, Karl Marx (Mar-k-S). +Logically speaking, Marx argues, there is an amount of time that goes missing in the worker's employment by way of a wage, when he advances some portion of his time to the capitalist in exchange for a pay-check one or more weeks later. +I refer the reader interested in the details to #link("https://www.marxists.org/archive/marx/works/1867-c1/ch20.htm")[chapter 20 of #emph[Capital] Vol. I];: but the essential point here is that it is through an obfuscation of the real value of a worker's time that the capitalist manages to produce surplus-value. +The production of this kind of time-distorted surplus-value is the engine of capitalism as a social relation that appears, on the surface, to be equally fair to capitalist and worker alike. +So the project of controlling 'perceptual chronologies' with which Lumon seems to be so concerned is perhaps not as esoteric and inessential as it might at first seem. Perhaps it is an embodiment of the core ingredient of the company's success as a company, of its incorporation as an entity that ought to be sustained even at the expense of its members' happiness, their health, and their livelihoods. diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-2.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-2.typ new file mode 100644 index 0000000..1fcc98b --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-2.typ @@ -0,0 +1,122 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 2 + +#import "index.typ": template +#show: template + +#set document(title: [Half Loop - _Severance_ [s1/e2]]) + +#title() + +In #link("./severance-ep-1.typ")[the first episode], we were introduced to the two-sided subject at Lumon. +On the one hand, there is Mark S, the innie, who is screened for the first and major part of the episode. +On the other, Mark Scout, the outie, to whose predicament we are introduced in the concluding scenes. +S1E2 opens with a rewind on how innie Helly R came to be: how Milchick handed her flowers at end of her first day (which we glimpsed in S1E1 when Mark almost ran her over), a glimpse of her confidence gliding into the operating room on a higher floor of the same Lumon complex we saw Mark leave, a stereoscopic view of the implant procedure by which she becomes an android whose existence is #quote[spatially dictated] by Lumon's mysterious machinations. + += Lumon Industries + +#image("img/severance-s1e2-lumon-logo.png") + +Lumon is a corporate pastiche, and not only of technology companies. +Lumon seems to have its hands in surgical hardware (the operating room equipment), digital technology ('Macrodata Refinement'), and medicines and topical salves (as discussed at the dinner party in S1E1 - #quote[What don\'t they make?]). +It is a quintessentially American jack of all trades, a global power in its own right cohered by a family dynasty---the Eagans---recalling the Du Ponts or the Rockefellers. + +The more obvious comparison to make, however, is between #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/1fb28nq/apple_lumon_are_weridly_similar/")[Lumon and Apple], perhaps in part because the show screens on Apple TV Plus. +The style of the computers on the severed floor recalls #link("https://www.historytools.org/docs/computer-history-timeline-personal-computers-computing-internet")[the dawn of the era of personal computing] in the 1970s and 80s, an aesthetic imaginary in which Apple plays an important role. + +Indeed, the aura of Lumon as a futuristic computing corporation from the late 70s is reinforced by the fact that its headquarters are shot at #link("https://ethw.org/Bell_Labs")[Bell Labs] in New Jersey, a building that has now been renovated as a mixed-use office for high-tech startup companies as #link("https://en.wikipedia.org/wiki/Bell_Labs_Holmdel_Complex")[Bell Works]. +Bell Labs is the quasi-mythological source in the contemporary corporate technology culture (Silicon Valley) of the idea that a certain kind of research freedom characterized by open-ended product delivery timelines and serendipitous encounters in open office plans can cultivate ground-breaking technology. +(Mark Zuckerberg #link("https://www.businessinsider.com/mark-zuckerberg-recommends-the-idea-factory-2015-11")[recommended] a book on Bell Labs as one of his #quote[important books] of 2015.) +The irony of this setting, of course, both in _Severance_ and in the technology companies it parodies in the American landscape in 2025, is that the workplace has never been more saturated with surveillance and micro-management. +The overhead shot of Helly R that opened the series is indicative here again, as is the complementary overhead of MDR's desks we get in this episode: there is always something watching from above, it seems, even if what it captures of the actual activity is a flattened and at times misrepresentative image. + +There are also evocations of Microsoft and IBM in Lumon, such as the #link("https://en.wikipedia.org/wiki/Office_Assistant")[Clippy];-like guide on the manual handed to Helly in the episode, or the apparent requirement of suits on the severed floor echoing #link("https://www.reddit.com/r/AskHistorians/comments/7l9ncw/comment/drkzual/")[IBM's infamous strict dress code]. +Lumon is a melange of imaginary pasts, presents, and futures in American innovation. +It is futuristic in the framing of its bio-technological project of perceptual management---and in the #quote[data smuggling] detectors that are installed in the elevators to the severed floor, about which more soon---but retrofitted in its aesthetic, in its management style, and in its outdated repertoire of daily devices. +Recall, for example, Milchick's handheld camcorder, and the tube-activated (vacuum-tube?) camera he uses to snap the official photo +of the new group of refiners. + +#image("img/severance-s1e2-bell-works.png") + +The overhead of Lumon Industries itself depicts a sketchy graph of a brain, one can't help but think. +Its upper floors all operate above board with normally conscious workers, whereas underground there is something sensitive enough happening so as to require extra precautions. +In #link("./severance-ep-1.typ")[S1E1's analysis], we introduced the idea that Lumon's interest in severing workers has to do with the mechanics of capital, in that surplus value can only ever be produced (in Marx's account) through the structural theft of time from its laborers.#footnote[#link("https://fi2.zrc-sazu.si/en/sodelavci/bostjan-nedoh-en")[Boštjan Nedoh] has evocatively called this operation #quote[theft without a thief].] +Lumon's spatial layout suggests that there might also be a psychoanalytic metaphor at stake in severance as an operation, where the happenings that occur in the business brain's basement are essential to what it really is, why it does what it does. + +Though Freud's theory has been popularized as a topographical notion, wherein the unconscious is the submerged part of the mind's iceberg of which we only see the tip, there is good reason to believe that this spatial description misrepresents how the unconscious should be properly understood. +Lacan thus preferred _topological_ descriptors to suggest that, if the unconscious is a 'place' or 'site', it contradicts any over-simplistic understanding of spaces that are distinctly separable. +The relationship between the conscious and the unconscious in a psychoanalytic theory of the subject, I would suggest, is better understood through the figure of a coin with two inseparable sides. +The meaning of any one side ('heads') derives from the meaning of its opposite ('tails'); and it is thus insensible to imagine separating one part from the other without repressing something fundamental about the structure of the subject as a whole. + +Lumon, though, seems to want desperately to keep innies from being in contact with their outies. +Indeed, the very project of severance seems to have something fundamental to do with managing repression effectively, with renovating the worker into a perfectly divided self that cannot complain about the conditions of her labor through the fact of not knowing anything about them. +(When Mark is given a dinner coupon on account of his head injury in S1E1, the real cause of the scar---Helly R's riotous attempt to escape the orientation room---is not revealed to outie Mark.) +The subject in _Severance_ is split and maintained as such. +The 'unconscious' of one's home life should not affect one's 'conscious' ability to perform at work. + +The vice-versa is also true. +Outies cannot suffer the 'unconscious' of their innies, either. +Mark Scout's decision to sever himself seems to be an attempt to repress the devastating effect of his experience of his wife's death for some part of the day, given that he admits he was unable to continue his job as a history teacher due to alcoholism. +At Lumon, however, Mark's alcoholism is brutally functional; as his innie must suffer what (lack of) energy he is given by outie Mark's actions the night before (#quote[I find it helps to focus on the effects of sleep since we don't actually get to experience it]). + +The intellectual impoverishment of Lumon's severed workers is further exposed in this episode as Dylan tries to convey to Helly the substance of what there is to live for as a severed innie: his #quote[embarrassment of wealth] that consists of finger traps, a caricature portrait, and the hope that there might be a #quote[waffle party] on the horizon. +The sad satisfactions that severed workers aspire to reinvigorate the sense of the phrase #quote[wage slavery], an important formulation that in fact has solid footing in Marx's analysis of capital. +For Marx, it is worth comparing the wage worker's predicament to the slave's; for both must labor not for themselves, for their own ends and aspirations, but for an external master that appropriates their efforts. +The important distinction is that, while in _actual_ slavery the slave's enthrallment to the master is explicit and explicitly enforced by means of force, in _wage_ slavery the figure of the master is more diffuse, and hierarchical distinctions are 'justified' in the discursive suggestion of their being fairly and freely established. +The proletariat (wage laborer) is free to choose her own master on the market, selling her labor power to whomever she chooses. +But she is not free to refuse to sell her labor as labor-power; as this #quote[wage slavery] is the generalized means of her reproduction and ability to go on living. +So the proletariat is enslaved to a structure, not a person, and that structure is characterized by the reduction of labor in its multifarious forms to labor-power, a measurement of labor in time that thus becomes exchangeable on the market. +In capitalism, in other words, freedom is structurally reduced to the freedom to choose to whom one sells one labor-power: which is #emph[not] the same thing as freedom tout court. +Thus is the wage laborer unfree in a way that is comparable, though not equivalent, to the slave. + += Death at Lumon +The death culture at Lumon should also be doubly refracted through Marx's analysis of how capital reduces its workers to shadows of themselves on the one hand, and a psychoanalytic understanding of the subject on the other. +When Mark gets emotional about Petey's disappearance during the game of office introductions (which tellingly involves passing around a brignt red ball), Milchick reprimands him with the following explanation: + +#quote(block: true)[ + I think this is a good time to remind ourselves that things like deaths happen outside of here. + Not here. + A life at Lumon is protected from such things. + And I think a great potential response to that from all of you is gratitude. +] + +Severed workers are insulated from death because the very structure of their subjectivity distances the meaning of its concept. +Innies symbolically 'die' when their outies do not come back to work, but this event does not necessarily coincide with their physical death, which as Milchick suggests should only be imagined to take place in the world of their outies. +There is a contradiction here, though, as a physical accident at work would propagate through to an innie's outie. +So Milchick's repression of the notion of death must be recognized as just that: a repression of a certain moment in or dimension of logic (a moment that is too dangerous or frightening to imagine saying out loud), and not as an explication of the necessary consequences of a thorough logic of life. + +Milchick's philosophizing also points to something more sinister in the structure of the severed subject. +The severed worker is protected from death, perhaps, because there is a sense in which he is already #emph[undead]. +Doomed to exist in the artificial enclosure of Lumon's basement and placated only by the pathetic enjoyments of finger traps, company coffee, ideological art, and the odd waffle party, what is there, _really_, to live for at Lumon? +The motto briefly shown on the implant hardware in Helly's operating room scene at the episode's opening has a morbid resonance here: #quote[Don't live to work. Work to live.] + +There is a stronger psychoanalytic sense in which we might make sense of Milchick's discourse on death that is worth mentioning here, too. +Lacan articulates a distinction between two kinds of death in his theory of the subject, a first death that is #strong[biological] and a second death that is #strong[symbolic]. +I will explicate this theory later in S1, when Milchick's foreshadowing of death's importance in the show bubbles clearly to the surface in a later episode. + += Capturing and controlling the symbolic +Let's talk about the #quote[symbol detectors] in the elevators, which are introduced in this episode. +These are the real basis of how Lumon separates innies from outies, as they supposedly ensure that no notes, no language, is passed between the two kinds of self. +In S1E1, we saw outie Mark put the tissue he had been crying into in his car in his pocket; and we then saw innie Mark confidently strolling out of the elevator on the severed floor, quizzically discovering the tissue in his pocket, and tossing it into a bin on his walk down the hall to MDR. +So the suggestion has already been planted in our (the viewers') mind that it is #emph[possible] to traffic objects across the boundary. +The other clear evidence of this is offered here in S1E2, where Irving similarly, quizzically, observes the black sooty substance underneath his fingernails during the distraction of the melon party. + +Yet Helly's note to herself triggers the alarms, resulting in the elevator doors refusing to close and a screen washed out with red alert. +So they do seem to have some power to detect 'symbols'. +But what marks the boundary between a symbol and a non-symbol for this technology? +It is not only explicit language in the form of written or spoken words that make meaning for us as human animals. +We are affected by a frightening range of other things; colors, tactile memories, qualities of our past selves that seep into our present (such as too much alcohol drunk the night before). +So it is hard to imagine, knowing the complexities of our selves as we all do, that Lumon could really effectively police the boundary between innies and outies, even with its back-to-the-future technological prowess. + +Indeed, the audio recording that innie/outie Petey shows outie Mark in his hideout at the greenhouse reveals the insecurity of symbol detection at Lumon. +In order to get a recording of what he was subjected to in the Break Room, he must have been able to get that retro handheld device back up into the 'real' world. +So either the elevators weren't able to pick it up, or there is some other way for innies to move between the supposedly demarcated spaces. +Either way, the symbol policing at the innie/outie border seems to have some shortcomings. + +A brief note on Petey's dishevelled greenhouse to conclude, as this episode is where we are first introduced to much of the geography that will become important in the series: the break room, wellness, MDR, optics and design, Mark's basement, the company restaurant (where Mark has his insufferably awkward date), the elevator, the MDR kitchen, the operating room, the Lumon foyer. +Petey's greenhouse, like many of the spaces in #emph[Severance], is a graph that both embodies and reflects a psycho-social moment of the show. +Green like Macrodata Refinement, but much less put-together, the greenhouse reveals the underside of Lumon's apparent glaem, the unconscious damage that its project of perfection wreaks on its workers psychologically and physically. +Petey shows us that the worker, like so many words and things in the show, is not simply what it seems, but consists also of an excess signification that inevitably creeps into its conspicuous comportment. +Mark is a depressed drunkard on the outside, and Irving (it seems) has his fingers in some hellish kind of black pie, a color that takes over his desk as he dozes off when he lets the distinction between his waking and unconscious self slip, we might say, when the reality of sleep threatens the security of being awake. +There is, as the imagery in the poster of the 'Whole Mind Collective' that motivates Mark to bunk off and follow up on Petey's enigmatic red letter suggests, a real revolution of sorts brewing beneath the surface of a fantasy of symbolic control. diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-3.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-3.typ new file mode 100644 index 0000000..8c1f032 --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/severance-ep-3.typ @@ -0,0 +1,197 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 3 with images + +#import "index.typ": template +#show: template + +#set document(title: [In Perpetuity - #emph[Severance] [s1/e3]]) + +#title() + +#image("img/severance-s1e3-shot1.png") + += We need to talk about Ms. Cobel +As we noted in #link("./severance-ep-1.typ")[analysis of S1E1], she typically storms the screen with an icy blue, a temper (the significance of this word we shall unpack shortly) that seeks to quell the fiery red that flickers in and out of the consciousness of workers on the severed floor. +The ominous ending to that first episode intimated that, while her wintery business has its office underground, it also warrants her prying into Mark's outie's personal life in Baird Creek's subsidized Lumon housing. +Indeed, it seems that Miss Cobel lives in Mark's housing complex, too. +From the state of her fridge, though, which we see in the foreground of a shot that implies surreptitious surveillance at work in her intimate space-- a sense that has already been produced in Mark's home with objects littered in the frame's foreground-- it doesn't appear that she spends very much time making a home there. +(Not too unlike Mark, perhaps.) + +Ms. Cobel is a kaleidoscopic vector of strange femininity in the show. +She is at once old widow next door, a girl-boss superior on the severed floor, and a little girl prone to tantrums. +As Mrs. Selvig, the hare-brained widow next door, she offers Mark unwanted company and cruddy cookies. +Yet we know by now that this is apparanetly a ruse, a senile disguise through which the conniving Harmony Cobel can keep an eye on her employee, Mark, beyond the bounds of his time at work. +At Baird Creek, she is a middle-aged executive in the clothing of an older and less cognitively composed character. + +But even if Ms. Cobel is the 'real' Mrs. Selvig, there is still something anile about her character. +She can be both comandeering and childish, as we see in her encounter with (innie) Mark S when he arrives unannounced at her office to request a kind of permission to take Hellie to the perpetuity wing in S1E3. +Commandering, because she accosts Mark with bureaucratic demands in her role as his boss (#quote[And have you filled +out a common-reservation slip?]). +Childish, because she literally throws a mug at him out of a petty frustration that is unbefitting of a mature manager. +Cobel rationalizes her childish temper as follows: + +#quote(block: true)[ + What I just did was something I knew that you could handle and grow from. + It was very painful for me. + I hope that you'll let it help you. +] + +This outburst locates something undecided within Ms. Cobel, a moment in relation to Mark where she lets her personal anger supersede her role as his manager. +This mug-throwing episode demonstrates that Cobel, too, is capable of breaking character as head of the severed floor and allowing some other aspect of her self to seep in, despite the pretense of a calm composure. +The thrown-mug, in other words, is the wish fulfillment heralded by Cobel's stunningly funny, inappropriate remark to Hellie during her orientation in S1E1; #quote[I've wanted to pummel Mark myself, but I am his employer.] +Even Cobel, who is supposed to be more in charge of herself than the MDR employees who are her inferiors-- her breaking into Mark's house while he isn't there implies is that she is unsevered, and thus more 'responsible'-- harbours desires that exceed and contradict the prescribed role she is supposed to play. + +The image of Cobel above confirms her as childish in some respects. +Notice that here, at 'home', she wears her hair in pigtails rather than loosely around her shoulders. +But it also paints her as a scopophilic and overbearing #emph[mother]. +Whatever she is doing creating excuses to talk to Mark's outie as Mrs. Selvig, it becomes clear in this episode that there is a convoluted kind of care at stake in her creepy and overcurious work. +Peering at him as he wanders up from the basement (Cobel doesn't seem to know that Petey is also down there at this point, though her break-in later in the episode suggests that she suspects something is awry), she murmurs to herself, #quote[Oh, Mark. Are you all right?] + +This is a strange exhibition of affection, coming from the same woman who will throw a mug at Markfor his failure to #quote[get MDR to its numbers] as department chief, who knowingly subjects him to the break room-- which we observe on screen for the first time later in the episode-- and who steals the book left by his brother-in-law as a gift at his doorstep. +Despite these mistreatments, Cobel does still seem to hold some perverted penchant for and attachment to Mark. +As HaxDogma notes in #link("https://www.youtube.com/watch?v=JAhhVnevSm4")[his review of this episode], it is hard to see Mark's promotion to department chief after Petey disappears as anything other than a nepotistic appointment, given that Irving is clearly the more experienced refiner in a number of respects (orientation procedure, group photo protocol, number of years spent on the severed floor, to name a few). +Cobel's overinquisitive manner on display in this episode is perhap best described as motherly, even as she is certainly not a paradigmatically #emph[good] mother. + +There is also something undoubtedly sexual about Cobel's relationship to Mark. +Her lingering at the door in S1E2 waiting to be invited in, her awkward and suggestive mention of her late husband's building an apartment in the back of their abode in heaven #quote[in case I found a new man before I got there], her creating an excuse to talk to him by pretending to de-ice her stoop; and, naturally, her peeping at him through the window. +She is either a stalker by-the-book, or (more charitably) a lonely woman who is searching for some missing satisfaction. +Most likely, she is an inextricable concoction of the two. +Cobel wants to have Mark's cake and eat it too; to be at once his mother, his corporate superior, and (we can't help but suspect) his lover. +Like many put in positions of power, she has trouble setting her more inapproriate desires aside so as to simply 'do her job'. + += Primal father figures +Cobel's mother energy is arguably muted and mixed up in her #link("https://en.wikipedia.org/wiki/Sphinx#Riddle_of_the_Sphinx")[Sphinxesque] triplicity. +But the father energy on display in this episode is, by contrast, loudly and proudly pronounced in at least three different figures: Petey, Irving, and, of course, Kier Eagan. +#footnote[ + There is foreshadowing, too, of a fourth father figure in Rickon, Mark's brother-in-law. + While reading his confiscated book, Milchick quietly remarks to himself a thought that will become an important refrain for many other characters with respect to Rickon later in the season: #quote[This is… Jesus.] +] +Before tackling these fathers one by one, it is instructive to straightforwardly and schematically lay out the #strong[Oedipus complex], an 'absolute fiction' that nonetheless, Freud claims, depicts something foundational about the graph of the speaking subject, the graph in which we took interest in #link("./severance-ep-1.typ")[our analysis of S1E1]. + +The Oedipus complex is so-named because it takes its architecture from the figure of Oedipus as he appears in the ancient Greek playwright #link("https://www.cliffsnotes.com/literature/o/the-oedipus-trilogy/about-the-oedipus-trilogy")[Sophocles' trilogy], which consists of the plays #emph[Oedipus Rex], #emph[Oedipus at Colonus], and #emph[Antigone]. +(Oedipus' tragic tale is drawn from a mythology that predates these plays, but the story is nonetheless usually traced to its Sophoclean production.) +Oedipus is well-known to students of psychoanalysis because of Freud's making him into a #link("https://nosubject.com/Oedipus_complex")[complex], which is generally (mis)understood as 'every person wants to kill their father and fuck their mother'. +Famously, Oedipus killed his father-- at a crossroads, thinking he was simply a threatening stranger at the time-- and married his mother-- not understanding that relation in the moment of the act, either. + +Jacques Lacan rendered the Oedipus complex more philosophically significant than this overblown and crude Freudian telling. +For Lacan, the Oedipus complex designates an abstract account of how desire is produced by the speaking subject in relation to the formative figures with which it is in relation. +As he notes in one of his 1938 text, #emph[The Family Complexes]: + +#quote(block: true)[ +our criticism since Freud presents this psychological entity [the Oedipus complex] as #emph[the specific form of the human family] and subordinates all social variations of the family to it. @lacanFamilyComplexesFormation2002[p.35] +] + +The Oedipus complex is not so much a diagnosis of a particular perversion that is presumed universal, in the sense that everyone #emph[consciously] suffers by repressing these secret dual desires to kill (my father) and to fuck (my mother). +It is rather an important part of how he architects a philosophy of the subject's relation to itself (and others) by way of a #quote[triangular conflict] @lacanFamilyComplexesFormation2002[p.41] between three figures: one's self, the Mother, and the Father. + +The Mother is the subject's first known object that is seen as separable from one's sense of self. +We can imagine this through the process of weaning, of a mother teaching her baby that sustenance ought to be sought in solid foods rather than directly from her teat. +Originally, a baby does not have a firm enough sense of itself to recognize that the Mother's teat is separated from its own body. +When it wants nourishment, it cries, and a breast brimming with milk appears (assuming a good mother, here). +The breast seems almost part and parcel of the baby, from its perspective, as what reason does it have to think otherwise? +(We are assuming here that the separation between a baby's sense of its own body and the world is not ingrained at birth, but rather learned, acculturated.) +It is only when the baby's crying stops precipitating a breast that it should start to doubt this part of itself, to think that perhaps my Mother's breast is not part of #emph[me] as subject but rather its own kind of thing, a separate object. +Thus the Mother is, in this developmental sense, the subject's first #emph[proper] object. +The Mother (and her breast), the baby subject thinks, is both mine and not mine, as though there is some #emph[relation] that my Mother has to me, she is not (quite) the same as me. + +The Father, on the other hand, incorporates (into) the baby subject's sense of self differently. +It is not considered, as the Mother is, a part of the subject that was at some point taken away, but rather represents the source of that action of taking away. +If the Mother #emph[ought] (in the terms of the baby subject's nascent ethics) to be a part of me, the Father is the force and figure responsible for taking her away. +This stature of the Father is better understood, perhaps, with reference to the myth of the #strong[Primal Father], which Lacan reinterprets from its presentation in Freud as originally depicted in the fourth and final chapter of #emph[Totem and Taboo] @freudTotemTabooResemblances1919. +Like the Oedipus complex, the myth of the Primal Father is a narrativization that helps to understand the structure of the subject. +Suppose a primal horde, Freud offers, at the helm of which exists a Primal Father who monopolizes all women. +All women in the horde, in other words, are sexually subject to this single male; no other male gets to enjoy anything of them. +A band of brothers, resentful of the Father's monopoly on enjoyment, conspire to escape the ban on sexual enjoyment through a plot to murder him. +#footnote[ + There has been much written on Freud's mythos of the Primal Father. + For a relatively recent use of the concept that serves as a reasonable introduction to Lacan's reading of #emph[Totem and Taboo], see @mcgowanDistributionEnjoyment2021. +] +They do so through what could be called an original jealousy, a feeling that the Father is enjoying in a way that is prohibited (by virtue of the Father's taboo) for each of them. + +Freud offers this as an #quote[historic explanation… [of] the origin of incest] @freudTotemTabooResemblances1919[p.207], as the Primal Father's taboo on enjoyment is what, Freud suggests, drives exogamy, wherein each of the band of brothers leaves that original tribe to start their own in which they can (finally) enjoy the women for themselves. +That this is an historic explanation does not mean that Freud believes that it represents an actual state of affairs in some distant past. +Indeed, he states the opposite, that #quote[primal state of society has nowhere been observed.] @freudTotemTabooResemblances1919[p.233] +The parable of the Primal Father is historic rather in the sense that narrates to us an important aspect of the structure of the subject, much like Oedipus' tragedy. + += Daddy issues at work + +Okay: we now return from this Freudian digression to the stuff of #emph[Severance]. +What bearing do the Oedipus complex and the myth of the Primal Father have on the structure of the subject on display in the show? +Let's go now to the scene in S1E3 at the crossroads, where MDR runs into two employees in Optics and Design (O&D). + +#image("img/severance-s1e3-shot2.png") + +The composition of this shot puts the reflective axis down the center, and the encounter is suggestively Oedipean in its structure (at a crossroads, unknowing of the Other at play). +Note that Irving is compositionally mirrored by Burt, played by Christopher Walken, and we will explore this suggestive symmetry in detail in later episodes. +The two departments (MDR and O&D) know #emph[of] each other, we surmise from the dialogue that follows. +But Irving isn't supposed to know Burt by name, as he accidentally happened upon him in S1E2 on the way to a Wellness session. +(Burt was coming #emph[from] his Wellness session.) + +While Irving greets Burt on the back of this previous encounter with gentle and flirtatious warmth, Dylan's hostility towards O&D is clear. +In place of the camaraderie that one might have hoped for between the two factions given their shared plight as severed workers, there appears to be an enmity built on a mythology (what Irving calls an #quote[absolute fiction]) of otherness: + +#quote(block: true)[ + Kier sorted the departments by virtue. + Macrodats are clever and true, while O&D's more cruelty-centered…. + O&D tried a violent coup on the others decades ago, and that's why they reduced them down to two. + And that's why they keep us all so far apart now. +] + +Kier is evidently the Primal Father of the severed floor, responsible for instituting the symbolic system of rules, regulations, and affects in the various 'bands of brothers' which reside there. +The tour of the perfect replica of Kier's house later in the episode reinforces his architectural status as Primal Father. +Irving chides Mark for his lack of reverence in deigning to turn the tour of the Perpetuity Wing into Eagan Bingo, and is aghast when he almost happens to #quote[bed sit] on the facsimile in his duplicate chambers. +(Thou shall not lie in Kier Eagan's bed.) +Kier and the lineage of Eagans more generally constitute the #link("https://nosubject.com/Law_of_the_father")[law of the father], the signifier of authority that keeps the severed floor's social order intact, the symbolic source from which both rules and the forbidden temptations of their being broken, taboos, sprout. Irving fosters this authority during the tour, standing in for the absent caregivers, existential (Kier, the Eagans) and material (Cobel and Milchick as superintendents who seem to be letting the kids take care of themselves for a short period). + +Another paternal authority whose absence has haunted and structured Mark since the show's opening is Petey, the man whose shoes he stepped into as MDR's department chief. +As per his exchange to Cobel in the mug-throwing scene, Mark lionizes Petey as a tone-setter, often acting through an ethics refracted by the subordinate conjunctive, 'if Petey were here', or the preface 'Petey used to say'. +Mark's innie is steered more by an imagined sense of what Petey would do, rather than what Kier would. + +Thus while it is Cobel who is explicitly in charge, the spectral presence of these father figures-- Kier, Petey, Irving-- correlatively structures the subject on the severed floor. +There is, in other words, an Oedipal triangular conflict at work in relation the ethical imperative of a severed worker. +The four members of MDR, as orientations to the structure of this subject, suffer different relationships to the positions of Mother and Father. +Mark S is a momma's boy, sired more by Petey's radical rejection of company policy than by Kier. +Dylan, though impertinent to the minutiae in the structure of Law at times, is ultimately his Father's son, acquiring satisfaction by accumulating accolades, and apparently driven by the impending idea of another finger trap or a waffle party. +Irving seems at this point the most mature of the children, looking reverentailly to Kier. +Yet recall that he has been chided by Milchick already for falling asleep on the job, so not all is perfect in paradise. +Hellie has no time for Cobel's authority, yet we will see in due course that her relationship with a Father is a deep lineament in her personality, too. + += Taming tempers +The count of four in the members of MDR mirrors the exact amount of tempers that we learn about from Kier Eagan's wax simulacrum speaking during the tour of the Perpetuity Wing. +These tempers are crucial as coordinates of the Eaganic attempt to coherently quantify the subject, and Kier's pronouncement is deeply significant for our investigation of the subject's distorted structure on the severed floor: + +#quote(block: true)[ + I know that death is near upon me, because people have begun to ask what I see as my life's great achievement. + They wish to know how they should remember me as I rot. + In my life, I have identified four components, which I call tempers, from which are derived every human soul. + #strong[Woe. Frolic. Dread. Malice]. + Each man's character is defined by the precise ratio that resides in him. + I walked into the cave of my own mind, and there I tamed them. + Should you tame the tempers as I did mine, then the world shall become but your appendage. + It is this great and consecrated power that I hope to pass on to all of you, my children. +] + +If there was any doubt that Kier Eagan embodies the Freudian Primal Father, the foundational component of absolute fiction on which the edifice of Law (the rules and taboos by which a subject is bound to abide) is constructed, the quotation above should put it to bed. +Kier's 'philosophy' seeks to conquer death by quantifying life, sorting its myriadic nature into a #quote[precise ratio] of character that can be counted (completely, it seems) in four distinct tempers. +Indeed, we saw the pictorial representation of this taming in s1e2, in the scene where Irving meets Burt: + +#image("img/severance-s1e3-tamingtempers.png") + +In the post-Platonic cave of his own mind, Kier is the master of his passions. +He admits no unconscious contours that sneak up on him unbeknowst in Freudian slips of the tongue or unwanted symptoms. +Indeed, the Eaganesque fantasy of the subject is one in which the necessary excess of language that psychoanalysis discovered does not exist. +Words are detected (via sensors in the elevator, say), controlled, managed. +Any psychoanalytic excess is, in Kier's project of a precisely rationalized subject, beaten out of language. +Excess meaning is 'tamed' as if it were a wild animal by a clear-headed, upstanding, divinely radiant visonary. +(As we will see, the position of primal power that Kier occupies here is sexually overbearing, too, as we might suspect from the Freudian analogy.) + +This episode ends with two scenes depicting the dark and bloody underside of Kier's waxen vision of the precisely quantified human subject. +The first is Helly's harrowing experience in the break room, a space where the unruly distance between words as they are uttered and the meaning they convey is thought to be stamped out, suffocated by the drudgery of debilitating repetition. +A subject will not exceed its authorized symbolization, the break room seems to want to claim. +The worker's unconscious will be tamed and ultimately made beholden to a regime of conscious rationality. +The second, and the closing scene of the epsiode, is Petey's psychotic demise at the convenience store, where he yells at wit's end: #quote[I need tokens so I can eat!] +Ravaged by the failure of his complete quantification inside Lumon, Petey seems no longer to have a firm footing in either his innie's or outie's reality. +Mark looks on from a distance as he collapses outside the store, escorted by police, attempting (it seems) to account for his disintegration. + +#bibliography("./references.bib", style: "chicago-author-date") diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/writing-in-typst.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/writing-in-typst.typ new file mode 100644 index 0000000..1861235 --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashindex_full_stoptyp/writing-in-typst.typ @@ -0,0 +1,132 @@ +// @rheo:test +// @rheo:formats html +// @rheo:description Blog post with HTML video elements and custom functions + +#let video(path, width: "auto", height: "auto", controls: "true", autoplay: "false", loop: "false") = { + html.elem("video", attrs: ( + src: path, + width: width, + height: height, + controls: controls, + autoplay: autoplay, + loop: loop, + style: "max-width: 100%", + )) +} + += Writing in Typst | Hacking on Neovim with Claude +== What is a 'good' writing system? +I have been #link("https://www.ohrg.org/devonthink-part-i")[incrementally] #link("https://www.ohrg.org/devonthink-part-ii")[hacking] #link("https://www.ohrg.org/devonthink-part-iii")[on] my writing environment for some time now, since at least 2013 when I started seriously using computers in undergrad. +A couple of years ago, I migrated to Orgmode as the best markup syntax for my needs, and #link("https://www.ohrg.org/writing-setup")[wrote aa post about how Emacs and Orgmode serviced my writing needs]. + +Here's a summary of that post and the core tenets of what I consider an acceptable writing environment, parsed out over the five or so years I've been experimenting with one through grad school: + ++ *Flexible, powerful and distraction-free*. + In short, this means that the environment needs to be an extension to a modal editor in the terminal. + I started using a #link("https://carlosbecker.com/posts/ed/")[modal text editor] around 2018, and use a range of ergonomic keyboards in #link("https://www.ohrg.org/cycling-typing")[funky ways] that make using a mouse undesirable in most cases. + (The web browser is the one environment where I still get some mileage out of a mouse. + I do a lot with keyboard shorcuts via #link("https://vimium.github.io/")[Vimium], but there are still some contexts where it's just quicker or more comfortable to use a mouse.) + One of the main reasons that I settled on Orgmode rather than, say, Markdown at the time was because of its #link("https://orgmode.org/manual/Citations.html")[more standardized bibliographic management]. + ++ *Non-proprietary and sane markup format*. + Microsoft Word documents and Google Docs are great for a lot of things, but I refuse to rely on either of them as a primary format for all of the writing I do, as their formats are to hard to parse (to write custom software for) and bound to Microsoft's and Google's ecosystems respectively. + The ability to run Unix-style comands on a simple markup format from a terminal to search and replace, for example, is an essential. + Writing documents in a plain-text markup language also gives me the safety of knowing that, if it really came down to it, I could write my own parser and compilers. + My writing archive shouldn't strictly rely on some company's infrastructure to host, search, or otherwise make use of the thought it contains. + Using such a format also means that cross-platform editing is made simpler and possible. + (I run linux mostly, but still regrettably use Android as my phone's operating system.) + ++ *Multi-format export*. + #link("https://willcrichton.net/notes/portable-epubs/")[Most of the world's documents are still PDF]. + There's no getting away from needing to export writing as PDF in many cases-- for e-readers like #link("https://www.ohrg.org/using-two-remarkables")[the reMarkable that I use], or for submission to conferences. + But we increasingly read writing on a web page of some sort, and so I also need a workflow to export fully functional documents to HTML and CSS, too. + Other formats that are interesting if not essential include some kind of presentation file (PowerPoint, or better: just a website that has slideshow-like interactions), Markdown for rich formatting to copy somewhere, and plain text. + +I have up until very recently used Orgmode as my markup language of choice, exported them to PDF with exported them to PDF with #link("https://www.latex-project.org/")[latex], and exported them to HTML with #link("https://pandoc.org/")[pandoc]. +But I am very attached to the Neovim ecosystem for my code editing and writing, and so it was clunky to open up an Emacs installation (that I barely understood) exclusively to edit Orgmode. +So I switched to editing Orgmode in Neovim along with everything else, #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[using plugins] and #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[custom functions] to get towards the writing experience that I wanted. + +This has actually worked surprisingly well, but it has some sharp edges. +One of the more significant ones is that any time I want to produce anything more complicated than basic, formatted text with citations and footnotes-- for all of which pandoc transformations produce reasonable output in both HTML and PDF-- I need to start embedding LaTeX into Orgmode, and deal with the LaTeX toolchain / dependency management in order to compile a PDF. +Similarly, if I want to produce an interactive HTML document, I need to embed the source code directly in Orgmode and ensure that the export process handles dependencies and the like appropriately. + +Some of this is unavoidable. +If I want to run custom Javascript in a website that is well beyond the expressive capacities of a markup language, at some point I just want to be able to write Javascript. +But what I found frustrating about my Orgmode / LaTeX / HTML workflow is that there wasn't any reasonable way to work towards extending the markup language in _some_ ways, unless I was willing to start developing my own bespoke flavor of Orgmode plus plus. +I also don't particularly like wrestling with the LaTeX ecosystem, because-- and this is hardly controversial to say-- #link("https://tex.stackexchange.com/questions/222500/why-is-latex-so-complicated")[LaTeX has a lot of bloat]. +What I wanted was a more _extensible_ system which had saner defaults. + +== Enter: Typst + +A few months ago, I started seriously considering #link("https://typst.app/")[typst] as a potential replacement for LaTeX. +At the very least, I thought, it would be more fun to wrestle with a modern ecosystem when struggling to produce some custom table or figure in my output PDF, as typst has a #link("https://typst.app/docs/reference/layout/")[layout system] that uses terms that are a lot more intuitive to me than the black magic of laying out LaTeX documents. + +It just so happened, however, that I started to follow typst development more closely at a time when the final touches to the #link("https://github.com/typst/typst/issues/5512")[basic foundations of HTML export], such as footnotes and bibliography, were just about to be added to the upstream. +So I made #link("https://github.com/typst/typst/pulls?q=is%3Apr+author%3Abreezykermo+is%3Aclosed")[a few contributions] to spirit it along, and started more serious experimentation using typst as a unified way to produce _both_ PDF and HTML in my writing environment. +Pandoc #link("https://pandoc.org/MANUAL.html#typst")[can convert to and from typst], so I originally intended to keep writing documents in Orgmode and then transiently convert them to typst in order to produce PDF and HTML both. +But I quickly found that the typst syntax natively accommodates all of the features that I make use of regularly in Orgmode such as citations, footnotes, headings, links and text decoration-- and then some. + +So why not write my blogs, papers, and documents directly in typst? +I considered the critical features of my Neovim / Orgmode writing environment that I didn't want to abandon: + ++ *Shortcuts for markup*. + The #link("https://github.com/nvim-orgmode/orgmode")[nvim-orgmode plugin] makes writing Orgmode in Neovim pleasurable, providing shortcuts to insert a link and basic text decoration while composing. ++ *Citation and link picking*. + Though I've gone without it for a few months for reasons that are immaterial here, I used to have a shortcut to bring up a fuzzy finder for all of my bibliography entries to easily insert a citation. + The same fuzzy finder would make it easy to link to local files (in a website, for example, to link to other posts). ++ *Document folding*. + The ability to fold away all of the text beneath a heading is very useful when navigating larger documents, as it helps me to compartmentalize writing tasks and organize longer documents such as a dissertation chapter. ++ *Export shortcuts*. + I have customized my Neovim editor so that I can easily export the active Orgmode document (through the pandoc and LaTeX processes described above). + Personally, I don't feel that I need a real-time live preview of the document as I type, as I generally just want to check that it looks reasonable at certain junctures in the writing process, rather than continuously. + +The one other features of Orgmode that I have come to rely on heavily is its #link("https://orgmode.org/manual/TODO-Basics.html")[TODO functionality]. +I typically only use this in notes related to projects or tasks more generally, however, and not in documents that are intended for publication such as a paper or blog post. + +== Enter: Claude Code +At this point in the past of a new writing technology's prospecting, I would go searching for a Neovim plugin for typst and hope that it provides features that satisfy a majority of these requirements. +I've spent a fair bit of time #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua")[tinkering with my init.lua], the entrypoint for customizing Neovim, but I've never had the time nor interest to sit down and write a plugin from scratch. + +LLMs, of course, are at time of writing taking the coding world by storm. +I have started moderately relying on #link("https://github.com/anthropics/claude-code")[Claude Code] when writing some-- though certainly not all-- kinds of code. +As is well-known by now, Claude is especially good at scaffolding hacky scripts or modules from scratch, when no large codebase or domain-specific knowledge needs to be kept in context. +A Neovim plugin, I realized this morning, is a pretty ideal domain for LLM-assisted coding. +The 'codebase' is often just a single configuration file, and the domain-specific knowledge is the Neovim editor itself, a well-documented and expansively customized software for which there are many examples on Reddit.#footnote[It's impossible to mention LLM coding at this time without adding some sort of disclaimer that, no, I don't think AGI is around the corner, and yes, I do expect both programming languages and language writ large to remain 'a thing' in the foreseeable future. LLMs are an incredibly powerful tool to write and analyze code and text, but the purpose of code and text-- as a medium of symbolic communication amongst social beings-- has not been rendered valueless since ChatGPT became publically available. If anything, the value of adeptly and adroitly handling written language has taken deeper root. For my preliminary thoughts on why we are so keen to imagine that computers will supplant the usefulness of the human, I refer the reader to #link("https://caiml.org/dighum/announcements/digital-humanism-salon-capital-and-the-computer-by-lachlan-kermode-2024-06-24/")[this talk I gave in 2024].] + +So I fired up Claude Code earlier this afternoon, and-- fast-forward an hour or two-- I have a fully functional writing environment for Typst that essentially has feature-parity with my Orgmode environment. +Moreover, my #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim")[Neovim config] is now much more comprehensibly modularized; and I have a tried-and-tested method for extending it without needing to spend days learning the ins-and-outs of Neovim's API; and #link("https://github.com/breezykermo/nixos/commit/67cdbbae0dd77db766289b7f6eb278091ab937dd")[some bugbears in my NixOS config were eliminated] while I was at it. +(If that last bit means nothing to you, count yourself lucky!) + +== My new writing environment +I use #link("https://tree-sitter.github.io/tree-sitter/")[treesitter] for syntax highlighting, and Typst already looks pretty good with it. +I get function completion #link("https://github.com/breezykermo/nixos/blob/main/home-manager/server/neovim/lua/plugins/lsp.lua#L17-L24")[by integrating an LSP for the format], for which I'm using #link("https://github.com/Myriad-Dreamin/tinymist")[tinymist]. + +As I noted above, I haven't had dynamic link or citation insertion for some time. +It was one of the features that got lost in my move from writing Orgmode in Emacs to writing it in Neovim. +I use #link("https://github.com/nvim-telescope/telescope.nvim")[telescope.nvim] for general search and file-picking when coding in Neovim, and I figured that I could use a customized pop-up to dynamically pick available citations from the relevant #link("https://www.overleaf.com/learn/latex/Bibliography_management_with_bibtex")[BibTeX] file, too. +After a few minutes of #link("https://simonwillison.net/2025/Oct/7/vibe-engineering/")[vibe-engineering], I have the following: + +#video("../img/typst-links-citations-demo.mp4") + +When I am writing in Typst, and I want to bring in a reference, I can open a panel. +Note that the search is full-text, not just using the reference ID. +I also have a shortcut to specify which bib file to use through the `#bibliography` function in Typst. +I can insert links in the same way as citations, both references files relative to the current one (blog posts on the same site), and external links. +Both the citation and link insertion work either by highlighting text and annotating it, or to insert new links/citations. +I also have a similar shortcut to add footnotes. + +This is pretty functional now for generic writing! + +== Future work +Typst isn't ideal for producing fully-featured websites currently, as HTML export is experimental. +Even when it becomes better supported, the project is-- understandably, given its priority supporting PDF-- taking a #link("https://github.com/typst/typst/issues/5512")[relatively conservative approach] to HTML generation. +Anything that doesn't have a robust analog in a PDF document, such as videos and hover panels, will have to be 'embedded' in Typst with HTML/CSS/JS, rather than being written in Typst syntax. +The current experience isn't much worse than Orgmode with Pandoc, though, and the Typst roadmap promises that it will become much better in the relatively short-term future. + +There is a longstanding issue that I've had with links in Orgmode that I haven't yet tackled with Typst. +When I'm writing, I like hyperlinked text to appear as it will in the final document, i.e. without the underlying URL on display. +When editing any particular line, though, it's better that all of the links are 'expanded' to their full source syntax (`#link("...")[...]`) so that its feasible to edit the markup without requiring any fancy shortcuts. +The effective shortening of lines that occurs when hiding these URLS results in different Neovim line-wrapping requirements, with which the Orgmode plugin I have been using does a bad job, giving ugly linebreaks in documents with long links. +This link presentation will likely be the next feature I add to my Neovim Typst plugin. + +I'll add to the capabilities in #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim/lua/typst")[my Neovim config files], and might eventually release a separate plugin if the features become significant/mature enough. diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot1.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot1.jpg new file mode 100644 index 0000000..15ec7c6 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot1.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot2.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot2.png new file mode 100644 index 0000000..86ef4bd Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot2.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot3.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot3.jpg new file mode 100644 index 0000000..5748da6 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e1-shot3.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-apple-II.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-apple-II.jpg new file mode 100644 index 0000000..34b4c3b Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-apple-II.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-bell-works.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-bell-works.png new file mode 100644 index 0000000..21af54e Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-bell-works.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-commodore-pet.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-commodore-pet.jpg new file mode 100644 index 0000000..a3a14cd Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-commodore-pet.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-ibm-pc.jpg b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-ibm-pc.jpg new file mode 100644 index 0000000..5fdd501 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-ibm-pc.jpg differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-lumon-logo.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-lumon-logo.png new file mode 100644 index 0000000..ef615ce Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-lumon-logo.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot1.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot1.png new file mode 100644 index 0000000..00d9360 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot1.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot2.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot2.png new file mode 100644 index 0000000..b0084f6 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e2-shot2.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot1.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot1.png new file mode 100644 index 0000000..dfa281e Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot1.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot2.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot2.png new file mode 100644 index 0000000..ba39e37 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-shot2.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-tamingtempers.png b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-tamingtempers.png new file mode 100644 index 0000000..6eaac35 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/severance-s1e3-tamingtempers.png differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/typst-links-citations-demo.mp4 b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/typst-links-citations-demo.mp4 new file mode 100644 index 0000000..a29ea41 Binary files /dev/null and b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/img/typst-links-citations-demo.mp4 differ diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/index.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/index.typ new file mode 100644 index 0000000..e80f8be --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/index.typ @@ -0,0 +1,37 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Main blog index page with post listings + +#let div(_class: "", ..body) = html.elem("div", attrs: (class: _class), ..body) +#let br() = html.elem("br") +#let hr() = html.elem("hr") +#let ul(_class: "", ..body) = html.elem("ul", attrs: (class: _class), ..body) +#let li(_class: "", ..body) = html.elem("li", attrs: (class: _class), ..body) + +#let template(doc) = { + doc + context if target() == "html" or target() == "epub" { + div[ + #br() + #hr() + #ul[ + #li[#link("./index.typ")[Home]] + #li[#link("https://lachlankermode.com")[Learn more] about me] + #li[#link("https://ohrg.org")[Read other musings]] + ] + ] + } +} + +#show: template + += Screening the subject + +_Screening the subject_ is a blog that analyses content on both the big and small screen in reasonable detail, i.e. episode-by-episode or scene-by-scene. +Contact us at #link("mailto:info@ohrg.org")[info\@ohrg.org] for enquiries. + +// Be alerted of new content by subscribing to the #link("https://screening-the-subject.ohrg.org/feed.xml")[RSS feed]. + +- #link("./severance-ep-1.typ")[Severance, s1/e1] +- #link("./severance-ep-2.typ")[Severance, s1/e2] +- #link("./severance-ep-3.typ")[Severance, s1/e3] diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/references.bib b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/references.bib new file mode 100644 index 0000000..f8543cd --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/references.bib @@ -0,0 +1,30 @@ +@article{freudTotemTabooResemblances1919, + title = {Totem and {{Taboo}}: {{Resemblances Between}} the {{Psychic Lives}} of {{Savages}} and {{Neurotics}}}, + author = {Freud, Sigmund}, + translator = {Brill, A.A}, + year = {1919}, + journal = {Moffat, Yard and Company}, + volume = {50}, + number = {1}, + pages = {94--95}, + publisher = {LWW}, + urldate = {2025-06-05} +} + +@misc{lacanFamilyComplexesFormation2002, + title = {Family {{Complexes}} in the {{Formation}} of the {{Individual}}}, + author = {Lacan, Jacques}, + year = {2002}, + publisher = {Antony Rowe London}, + urldate = {2025-05-22}, + file = {/home/lox/Zotero/storage/HAKMXWZ5/Lacan - 2002 - Family Complexes in the Formation of the Individual.pdf} +} + +@article{mcgowanDistributionEnjoyment2021, + title = {The {{Distribution}} of {{Enjoyment}}}, + author = {McGowan, Todd}, + year = {2021}, + journal = {European Journal of Psychoanalysis}, + volume = {8}, + number = {1} +} diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-1.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-1.typ new file mode 100644 index 0000000..cd00d88 --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-1.typ @@ -0,0 +1,111 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post with images, footnotes, and bibliography + +#import "index.typ": template +#show: template + +#set document(title: [Good news about hell - #emph[Severance] [s1/e1]]) + +#title() + +#image("img/severance-s1e1-shot1.jpg") + +The first thing to notice is the colour palette. +She is dressed in blue, but her hair is chestnut red. +It spills out for the frame of her figure into the table around it, blockaded at its border by chairs and a carpet clad in green, yellow, then green again; then gray. +The establishing shot is a bird's eye view of an unknown woman who is soon revealed to have been put in the board room by someone else's design, who learns about her predicament only by a man's voice that emanates from the little device that rests on the table along with the woman, arranged so that it aims directly at her head. + +This opening image is a graph of the subject's predicament on the severed floor at Lumon. +Blue is the company colour. +Employees are almost invariably dressed in shades of it-- navy, midnight, Prussian, Oxford, cobalt-- and more reliably so as we work our way up the hierarchy. +Red is unruly passion, the tone of tempers that itch to tear off the straitjacket directives, to disregulate the business-as-usual in which there is no obvious place for illicit activities. +Green is the accent of Macro Data Refinement, the division of Lumon in which the show's protagonists are employed. +The device directs a man's voice at a woman's body in an attempt to keep her tempers in check, to ensure her firecraft does not smoke out the staid edifice of personality management, to order her #quote[perceptual chronologies] accordingly. +(Later in the episode, we learn that she almost manages to #quote[break in] on the control room during that opening sequence: the solidity of its enclosure is threatened from the very first.) + +It is instructive to attempt to articulate the dynamics that this graph indexes before we start talking about other scenes in the show. +Graphs are not at one with what they represent, for in the decision to render 'data' in the very act of a representation, we both lose and gain distinction of the dynamics in question. +The voice that opens Helly R up to the world of Lumon's severed floor begins: #quote[Who are you?] +This question is a mistake. +We retroactively learn, in a later scene, that Mark S was in fact supposed to begin with a less interrogative, more perfunctory: #quote[Hi there, you on the table. I wonder if you'd mind taking a brief survey.] +As Irving puts it: #quote[You [Mark S] skipped the preamble]. +Helly R is thrust, by this accident, immediately into questioning not only herself, but also the self-assurance of the voice that interrogates her. +Does this voice in my head [she could be thinking] really know what it is doing? +Or is it just a role of similarly confused actors struggling to stick to a badly written script? + +#link("https://www.youtube.com/watch?v=QIsLXuVeUgM")[This episode-length recap] of the first episode names this graph 'the Helly incident', a poorly executed orientation of Helly's newfound subjectivity that can be blamed at one level on Mark S (for starting with the wrong part of the manual), at another on Mr. Milchick (for misguiding Mark while he was distracted setting up the visual feed), on Ms. Cobel (for giving Mark Petey K's old manual without redacting his obscurely scribbled notes and paper bookmarks), or even on Irving (for neglecting to intervene and clarify how Mark should begin being the more senior refiner in the situation: #quote[Irving will be there to shadow. Just stick to the flowchart and escalate properly depending on dialectics.]). +Wherever to place blame, there is doubtless a misconfiguration that takes place. +Helly's instinctual reaction seems to be to try to kill the voice pointed at her head, rather than to befriend it as Mark states he did (where Petey was Mark). +(Helly will eventually have sex with the source of the voice, rather than murdering or fraternizing with it.) +In this episode, however, Mark (the voice's source) is physically assaulted by Helly, dented in his temple by the same vocalization device that mediated their first communication. + +#image("img/severance-s1e1-shot2.png") + +So this is the Macro Data refiner's situation. +On the one hand, she is affronted with a voice that compels her to abide by the rules and permits her to enjoy some small reliefs (egress from a locked room) if she concedes to it. +On the other, she is always teeming and thus flirting with red, considering escape routes that involve drawing blood, setting off alarms, or removing clothes. + +This unruly red is what Macro Data Refinement's greening procedures are supposed to contain to produce a completely controlled and scripted blot of blue. +Perhaps this is why the glipse of the vacant desks planned for the severed floor's expansion are draped in purple, for that shade of subjectivity would better incorporate the contrasting contours into a unified and taskable tone. +The red that threatens Lumon's corporate, calm, and collected blue (the Lumon logo is a water droplet that suspiciously resembles a camera) is splattered across scenes in the episode. +It is, for example, the envelope that Petey slips Mark at the company-owned restaurant #emph[Pip's] with the suggestion that he should read it if he wants to know #quote[what's going on down there]. +It is the sweater Mark wears to his sister's dinnerless dinner party, punctuated by red place mats (#quote[what a lot of people overlook, I think, is that life is not food]), where the ontological substance of his innie is called into question, and where we learn about the passions he has lost-- the history of World War II, educating, whiskey-- the last of which seems to have given way to an indiscriminate consumption of beer, wine, anything that will drown out the clarity of sober consciousness. +It is the general hue of his sister's house, which consisently wants him to question that placid blue of his company-subsidized housing at Baird Creek Manor. + +This dinner tells us something more about the subject in question in #emph[Severance]. +Just as Helly's outie had alerted us to the basic principle in the video her innie was shown in curiously lo-fi resolution to conclude her innie's orientation-- #quote[perceptual chronologies… surgically split]-- Mark's predicament is comparably explained to him by another more or less ignorant (we can't help but imagine) third party: #quote[One's memories are bifurcated, so when you're at work, you have no recollection of what it is you do there.] +As pretentious as they are, the dinner's guests do seem to be attuned to an important dimension of the meaning of life, which is that it can't #emph[only] be about satiating biological needs such as food. +What each individual 'needs' is a disharmonious melange of needs and demands, openings of desire that emerge not only through a graph of bare necessities-- food, water, shelter-- but also through capricious carapaces that emerge from more ambiguous pinings in the social sphere-- company, care, love. +The real question of Lumon's smooth functioning is whether it will be able to effectively plug up these pinings, the incidental moments at work where one wonders what one is really doing with one's life, whether the company can really manage its employees' unsanctioned thoughts and the way in which those illicit ideas seep into the daily practice of their workerhood. +More on the plasticity of our needs and drives to satisfy them in later posts. + +#image("img/severance-s1e1-shot3.jpg") + +Ms. Cobel, in contrast to Helly's and Mark's doubtful and doubting red, is a stormy and icy blue. +(We must wait until season two to uncover the historical and psychological depth of this colour for Harmony Cobel.) +She is the figure with a body that seems to be the most in charge, of those we meet in this episode. +Though Ms. Cobel is not a master in herself, it seems, for she too is subjected to a disembodied voice-via-device, 'the board', albeit which only appears evidently as an ear so far (#quote[The board won't be contributing to this meeting vocally]). +Cobel is responsible for keeping the severed floor's uncertainty in check, the 'head' that sits atop the variegated limbs of its disobedient body. + +When Cobel reprimands Mark for his derailing of Helly's orientation, she recalls an obscure and theological aspect of her parentage: + +#quote(block: true)[ + You know, my mother was an atheist. + She used to say that there was good news and bad news about hell. + The good news is, hell is just the product of a morbid human imagination. + The bad news is, whatever humans can imagine, they can usually create. +] + +At the close of the episode, just before Mark's senile neighbor Mrs. Selvig (who we have only heard about through Mark's voice thus far, when he is on the phone with her) visually reveals herself to be the same woman as Ms. Cobel, she gives a slightly different account of her heritage: + +#quote(block: true)[ + You know, my mother was a Catholic. + She used to say it takes the saints eight hours to bless a sleeping child. + I hope you aren't rushing the saints. +] + +It's unclear at this point whether Cobel is a severed worker like Mark, or whether there is some other reason for her (strange, almost senseless) duplicity. +Why lie about the religious leanings of one's mother? +Or maybe 'mother' is actually a name for something else, a kind of interim authority that gives synthetic weight to some hearsay, rumor, or idle phrase. +(The other cameo of an ambiguously defined mother in this episode is in question five of Helly's orientation survey: #quote[To the best of your memory, what is or was the color of your mother's eyes?]) +Perhaps it is that, severed or not, atheist or Catholic, Cobel's subjectivity is structured by a comparable split in her perceptual chronologies, whereby some memories (of her mother) get more airtime in her conscious experience of herself than others. + +#emph[Severance] flirts with this idea extensively, that the innie/outie dyad is analagous to the unconscious/conscious experience that we, as subjects, have of ourselves. +Mark's sister Devon hints at the psycho-logical reading of the severed condition in her diagnosis of Mark's morose (outie) predicament as a state of failed therapy in response to mourning for his late wife: #quote[I just feel like forgetting about her for eight hours a day isn't the same thing as healing.] +As with not-mothers and the plasticity of the drive, we will address the psychoanalytic implications here in later posts; but to finish I want to bring our attention to the #emph[imaging of time] at work in just this first episode. + +The fascinating details of failed synchronisation between all the watchfaces we see are enumerated in #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/txxbcm/observation_and_question_regarding_time/")[this Reddit thread]. +Many of the watch hands appear to be stalled, and the crossover from each to the next-- as when Mark Scout switches his wrist watch in preparation for his elevator descent into the workday of innie Mark S-- doesn't match with our experience of the actors on screen. +One of the few things we do know about the severance procedure is that it 'alters perceptual chronologies', and that this messing with a subject's sense of time is thought to + ++ make them more adequate or productive in a certain kind of work (for why else would Lumon go to the necessary lengths to sever some employees) ++ supposes to section off innie memories and experience from outie memories and experience + +So the subject's subjectivity is marked by its sense of time, and Lumon's success (profitability?) hinges in some way on altering their employees' stable sense of it while in the space of the severed floor. + +Mark S's temporal predicament here has been explained by a man whose last name we get by speeding up the saying of his own, Karl Marx (Mar-k-S). +Logically speaking, Marx argues, there is an amount of time that goes missing in the worker's employment by way of a wage, when he advances some portion of his time to the capitalist in exchange for a pay-check one or more weeks later. +I refer the reader interested in the details to #link("https://www.marxists.org/archive/marx/works/1867-c1/ch20.htm")[chapter 20 of #emph[Capital] Vol. I];: but the essential point here is that it is through an obfuscation of the real value of a worker's time that the capitalist manages to produce surplus-value. +The production of this kind of time-distorted surplus-value is the engine of capitalism as a social relation that appears, on the surface, to be equally fair to capitalist and worker alike. +So the project of controlling 'perceptual chronologies' with which Lumon seems to be so concerned is perhaps not as esoteric and inessential as it might at first seem. Perhaps it is an embodiment of the core ingredient of the company's success as a company, of its incorporation as an entity that ought to be sustained even at the expense of its members' happiness, their health, and their livelihoods. diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-2.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-2.typ new file mode 100644 index 0000000..1fcc98b --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-2.typ @@ -0,0 +1,122 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 2 + +#import "index.typ": template +#show: template + +#set document(title: [Half Loop - _Severance_ [s1/e2]]) + +#title() + +In #link("./severance-ep-1.typ")[the first episode], we were introduced to the two-sided subject at Lumon. +On the one hand, there is Mark S, the innie, who is screened for the first and major part of the episode. +On the other, Mark Scout, the outie, to whose predicament we are introduced in the concluding scenes. +S1E2 opens with a rewind on how innie Helly R came to be: how Milchick handed her flowers at end of her first day (which we glimpsed in S1E1 when Mark almost ran her over), a glimpse of her confidence gliding into the operating room on a higher floor of the same Lumon complex we saw Mark leave, a stereoscopic view of the implant procedure by which she becomes an android whose existence is #quote[spatially dictated] by Lumon's mysterious machinations. + += Lumon Industries + +#image("img/severance-s1e2-lumon-logo.png") + +Lumon is a corporate pastiche, and not only of technology companies. +Lumon seems to have its hands in surgical hardware (the operating room equipment), digital technology ('Macrodata Refinement'), and medicines and topical salves (as discussed at the dinner party in S1E1 - #quote[What don\'t they make?]). +It is a quintessentially American jack of all trades, a global power in its own right cohered by a family dynasty---the Eagans---recalling the Du Ponts or the Rockefellers. + +The more obvious comparison to make, however, is between #link("https://www.reddit.com/r/SeveranceAppleTVPlus/comments/1fb28nq/apple_lumon_are_weridly_similar/")[Lumon and Apple], perhaps in part because the show screens on Apple TV Plus. +The style of the computers on the severed floor recalls #link("https://www.historytools.org/docs/computer-history-timeline-personal-computers-computing-internet")[the dawn of the era of personal computing] in the 1970s and 80s, an aesthetic imaginary in which Apple plays an important role. + +Indeed, the aura of Lumon as a futuristic computing corporation from the late 70s is reinforced by the fact that its headquarters are shot at #link("https://ethw.org/Bell_Labs")[Bell Labs] in New Jersey, a building that has now been renovated as a mixed-use office for high-tech startup companies as #link("https://en.wikipedia.org/wiki/Bell_Labs_Holmdel_Complex")[Bell Works]. +Bell Labs is the quasi-mythological source in the contemporary corporate technology culture (Silicon Valley) of the idea that a certain kind of research freedom characterized by open-ended product delivery timelines and serendipitous encounters in open office plans can cultivate ground-breaking technology. +(Mark Zuckerberg #link("https://www.businessinsider.com/mark-zuckerberg-recommends-the-idea-factory-2015-11")[recommended] a book on Bell Labs as one of his #quote[important books] of 2015.) +The irony of this setting, of course, both in _Severance_ and in the technology companies it parodies in the American landscape in 2025, is that the workplace has never been more saturated with surveillance and micro-management. +The overhead shot of Helly R that opened the series is indicative here again, as is the complementary overhead of MDR's desks we get in this episode: there is always something watching from above, it seems, even if what it captures of the actual activity is a flattened and at times misrepresentative image. + +There are also evocations of Microsoft and IBM in Lumon, such as the #link("https://en.wikipedia.org/wiki/Office_Assistant")[Clippy];-like guide on the manual handed to Helly in the episode, or the apparent requirement of suits on the severed floor echoing #link("https://www.reddit.com/r/AskHistorians/comments/7l9ncw/comment/drkzual/")[IBM's infamous strict dress code]. +Lumon is a melange of imaginary pasts, presents, and futures in American innovation. +It is futuristic in the framing of its bio-technological project of perceptual management---and in the #quote[data smuggling] detectors that are installed in the elevators to the severed floor, about which more soon---but retrofitted in its aesthetic, in its management style, and in its outdated repertoire of daily devices. +Recall, for example, Milchick's handheld camcorder, and the tube-activated (vacuum-tube?) camera he uses to snap the official photo +of the new group of refiners. + +#image("img/severance-s1e2-bell-works.png") + +The overhead of Lumon Industries itself depicts a sketchy graph of a brain, one can't help but think. +Its upper floors all operate above board with normally conscious workers, whereas underground there is something sensitive enough happening so as to require extra precautions. +In #link("./severance-ep-1.typ")[S1E1's analysis], we introduced the idea that Lumon's interest in severing workers has to do with the mechanics of capital, in that surplus value can only ever be produced (in Marx's account) through the structural theft of time from its laborers.#footnote[#link("https://fi2.zrc-sazu.si/en/sodelavci/bostjan-nedoh-en")[Boštjan Nedoh] has evocatively called this operation #quote[theft without a thief].] +Lumon's spatial layout suggests that there might also be a psychoanalytic metaphor at stake in severance as an operation, where the happenings that occur in the business brain's basement are essential to what it really is, why it does what it does. + +Though Freud's theory has been popularized as a topographical notion, wherein the unconscious is the submerged part of the mind's iceberg of which we only see the tip, there is good reason to believe that this spatial description misrepresents how the unconscious should be properly understood. +Lacan thus preferred _topological_ descriptors to suggest that, if the unconscious is a 'place' or 'site', it contradicts any over-simplistic understanding of spaces that are distinctly separable. +The relationship between the conscious and the unconscious in a psychoanalytic theory of the subject, I would suggest, is better understood through the figure of a coin with two inseparable sides. +The meaning of any one side ('heads') derives from the meaning of its opposite ('tails'); and it is thus insensible to imagine separating one part from the other without repressing something fundamental about the structure of the subject as a whole. + +Lumon, though, seems to want desperately to keep innies from being in contact with their outies. +Indeed, the very project of severance seems to have something fundamental to do with managing repression effectively, with renovating the worker into a perfectly divided self that cannot complain about the conditions of her labor through the fact of not knowing anything about them. +(When Mark is given a dinner coupon on account of his head injury in S1E1, the real cause of the scar---Helly R's riotous attempt to escape the orientation room---is not revealed to outie Mark.) +The subject in _Severance_ is split and maintained as such. +The 'unconscious' of one's home life should not affect one's 'conscious' ability to perform at work. + +The vice-versa is also true. +Outies cannot suffer the 'unconscious' of their innies, either. +Mark Scout's decision to sever himself seems to be an attempt to repress the devastating effect of his experience of his wife's death for some part of the day, given that he admits he was unable to continue his job as a history teacher due to alcoholism. +At Lumon, however, Mark's alcoholism is brutally functional; as his innie must suffer what (lack of) energy he is given by outie Mark's actions the night before (#quote[I find it helps to focus on the effects of sleep since we don't actually get to experience it]). + +The intellectual impoverishment of Lumon's severed workers is further exposed in this episode as Dylan tries to convey to Helly the substance of what there is to live for as a severed innie: his #quote[embarrassment of wealth] that consists of finger traps, a caricature portrait, and the hope that there might be a #quote[waffle party] on the horizon. +The sad satisfactions that severed workers aspire to reinvigorate the sense of the phrase #quote[wage slavery], an important formulation that in fact has solid footing in Marx's analysis of capital. +For Marx, it is worth comparing the wage worker's predicament to the slave's; for both must labor not for themselves, for their own ends and aspirations, but for an external master that appropriates their efforts. +The important distinction is that, while in _actual_ slavery the slave's enthrallment to the master is explicit and explicitly enforced by means of force, in _wage_ slavery the figure of the master is more diffuse, and hierarchical distinctions are 'justified' in the discursive suggestion of their being fairly and freely established. +The proletariat (wage laborer) is free to choose her own master on the market, selling her labor power to whomever she chooses. +But she is not free to refuse to sell her labor as labor-power; as this #quote[wage slavery] is the generalized means of her reproduction and ability to go on living. +So the proletariat is enslaved to a structure, not a person, and that structure is characterized by the reduction of labor in its multifarious forms to labor-power, a measurement of labor in time that thus becomes exchangeable on the market. +In capitalism, in other words, freedom is structurally reduced to the freedom to choose to whom one sells one labor-power: which is #emph[not] the same thing as freedom tout court. +Thus is the wage laborer unfree in a way that is comparable, though not equivalent, to the slave. + += Death at Lumon +The death culture at Lumon should also be doubly refracted through Marx's analysis of how capital reduces its workers to shadows of themselves on the one hand, and a psychoanalytic understanding of the subject on the other. +When Mark gets emotional about Petey's disappearance during the game of office introductions (which tellingly involves passing around a brignt red ball), Milchick reprimands him with the following explanation: + +#quote(block: true)[ + I think this is a good time to remind ourselves that things like deaths happen outside of here. + Not here. + A life at Lumon is protected from such things. + And I think a great potential response to that from all of you is gratitude. +] + +Severed workers are insulated from death because the very structure of their subjectivity distances the meaning of its concept. +Innies symbolically 'die' when their outies do not come back to work, but this event does not necessarily coincide with their physical death, which as Milchick suggests should only be imagined to take place in the world of their outies. +There is a contradiction here, though, as a physical accident at work would propagate through to an innie's outie. +So Milchick's repression of the notion of death must be recognized as just that: a repression of a certain moment in or dimension of logic (a moment that is too dangerous or frightening to imagine saying out loud), and not as an explication of the necessary consequences of a thorough logic of life. + +Milchick's philosophizing also points to something more sinister in the structure of the severed subject. +The severed worker is protected from death, perhaps, because there is a sense in which he is already #emph[undead]. +Doomed to exist in the artificial enclosure of Lumon's basement and placated only by the pathetic enjoyments of finger traps, company coffee, ideological art, and the odd waffle party, what is there, _really_, to live for at Lumon? +The motto briefly shown on the implant hardware in Helly's operating room scene at the episode's opening has a morbid resonance here: #quote[Don't live to work. Work to live.] + +There is a stronger psychoanalytic sense in which we might make sense of Milchick's discourse on death that is worth mentioning here, too. +Lacan articulates a distinction between two kinds of death in his theory of the subject, a first death that is #strong[biological] and a second death that is #strong[symbolic]. +I will explicate this theory later in S1, when Milchick's foreshadowing of death's importance in the show bubbles clearly to the surface in a later episode. + += Capturing and controlling the symbolic +Let's talk about the #quote[symbol detectors] in the elevators, which are introduced in this episode. +These are the real basis of how Lumon separates innies from outies, as they supposedly ensure that no notes, no language, is passed between the two kinds of self. +In S1E1, we saw outie Mark put the tissue he had been crying into in his car in his pocket; and we then saw innie Mark confidently strolling out of the elevator on the severed floor, quizzically discovering the tissue in his pocket, and tossing it into a bin on his walk down the hall to MDR. +So the suggestion has already been planted in our (the viewers') mind that it is #emph[possible] to traffic objects across the boundary. +The other clear evidence of this is offered here in S1E2, where Irving similarly, quizzically, observes the black sooty substance underneath his fingernails during the distraction of the melon party. + +Yet Helly's note to herself triggers the alarms, resulting in the elevator doors refusing to close and a screen washed out with red alert. +So they do seem to have some power to detect 'symbols'. +But what marks the boundary between a symbol and a non-symbol for this technology? +It is not only explicit language in the form of written or spoken words that make meaning for us as human animals. +We are affected by a frightening range of other things; colors, tactile memories, qualities of our past selves that seep into our present (such as too much alcohol drunk the night before). +So it is hard to imagine, knowing the complexities of our selves as we all do, that Lumon could really effectively police the boundary between innies and outies, even with its back-to-the-future technological prowess. + +Indeed, the audio recording that innie/outie Petey shows outie Mark in his hideout at the greenhouse reveals the insecurity of symbol detection at Lumon. +In order to get a recording of what he was subjected to in the Break Room, he must have been able to get that retro handheld device back up into the 'real' world. +So either the elevators weren't able to pick it up, or there is some other way for innies to move between the supposedly demarcated spaces. +Either way, the symbol policing at the innie/outie border seems to have some shortcomings. + +A brief note on Petey's dishevelled greenhouse to conclude, as this episode is where we are first introduced to much of the geography that will become important in the series: the break room, wellness, MDR, optics and design, Mark's basement, the company restaurant (where Mark has his insufferably awkward date), the elevator, the MDR kitchen, the operating room, the Lumon foyer. +Petey's greenhouse, like many of the spaces in #emph[Severance], is a graph that both embodies and reflects a psycho-social moment of the show. +Green like Macrodata Refinement, but much less put-together, the greenhouse reveals the underside of Lumon's apparent glaem, the unconscious damage that its project of perfection wreaks on its workers psychologically and physically. +Petey shows us that the worker, like so many words and things in the show, is not simply what it seems, but consists also of an excess signification that inevitably creeps into its conspicuous comportment. +Mark is a depressed drunkard on the outside, and Irving (it seems) has his fingers in some hellish kind of black pie, a color that takes over his desk as he dozes off when he lets the distinction between his waking and unconscious self slip, we might say, when the reality of sleep threatens the security of being awake. +There is, as the imagery in the poster of the 'Whole Mind Collective' that motivates Mark to bunk off and follow up on Petey's enigmatic red letter suggests, a real revolution of sorts brewing beneath the surface of a fantasy of symbolic control. diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-3.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-3.typ new file mode 100644 index 0000000..8c1f032 --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/severance-ep-3.typ @@ -0,0 +1,197 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Blog post about Severance episode 3 with images + +#import "index.typ": template +#show: template + +#set document(title: [In Perpetuity - #emph[Severance] [s1/e3]]) + +#title() + +#image("img/severance-s1e3-shot1.png") + += We need to talk about Ms. Cobel +As we noted in #link("./severance-ep-1.typ")[analysis of S1E1], she typically storms the screen with an icy blue, a temper (the significance of this word we shall unpack shortly) that seeks to quell the fiery red that flickers in and out of the consciousness of workers on the severed floor. +The ominous ending to that first episode intimated that, while her wintery business has its office underground, it also warrants her prying into Mark's outie's personal life in Baird Creek's subsidized Lumon housing. +Indeed, it seems that Miss Cobel lives in Mark's housing complex, too. +From the state of her fridge, though, which we see in the foreground of a shot that implies surreptitious surveillance at work in her intimate space-- a sense that has already been produced in Mark's home with objects littered in the frame's foreground-- it doesn't appear that she spends very much time making a home there. +(Not too unlike Mark, perhaps.) + +Ms. Cobel is a kaleidoscopic vector of strange femininity in the show. +She is at once old widow next door, a girl-boss superior on the severed floor, and a little girl prone to tantrums. +As Mrs. Selvig, the hare-brained widow next door, she offers Mark unwanted company and cruddy cookies. +Yet we know by now that this is apparanetly a ruse, a senile disguise through which the conniving Harmony Cobel can keep an eye on her employee, Mark, beyond the bounds of his time at work. +At Baird Creek, she is a middle-aged executive in the clothing of an older and less cognitively composed character. + +But even if Ms. Cobel is the 'real' Mrs. Selvig, there is still something anile about her character. +She can be both comandeering and childish, as we see in her encounter with (innie) Mark S when he arrives unannounced at her office to request a kind of permission to take Hellie to the perpetuity wing in S1E3. +Commandering, because she accosts Mark with bureaucratic demands in her role as his boss (#quote[And have you filled +out a common-reservation slip?]). +Childish, because she literally throws a mug at him out of a petty frustration that is unbefitting of a mature manager. +Cobel rationalizes her childish temper as follows: + +#quote(block: true)[ + What I just did was something I knew that you could handle and grow from. + It was very painful for me. + I hope that you'll let it help you. +] + +This outburst locates something undecided within Ms. Cobel, a moment in relation to Mark where she lets her personal anger supersede her role as his manager. +This mug-throwing episode demonstrates that Cobel, too, is capable of breaking character as head of the severed floor and allowing some other aspect of her self to seep in, despite the pretense of a calm composure. +The thrown-mug, in other words, is the wish fulfillment heralded by Cobel's stunningly funny, inappropriate remark to Hellie during her orientation in S1E1; #quote[I've wanted to pummel Mark myself, but I am his employer.] +Even Cobel, who is supposed to be more in charge of herself than the MDR employees who are her inferiors-- her breaking into Mark's house while he isn't there implies is that she is unsevered, and thus more 'responsible'-- harbours desires that exceed and contradict the prescribed role she is supposed to play. + +The image of Cobel above confirms her as childish in some respects. +Notice that here, at 'home', she wears her hair in pigtails rather than loosely around her shoulders. +But it also paints her as a scopophilic and overbearing #emph[mother]. +Whatever she is doing creating excuses to talk to Mark's outie as Mrs. Selvig, it becomes clear in this episode that there is a convoluted kind of care at stake in her creepy and overcurious work. +Peering at him as he wanders up from the basement (Cobel doesn't seem to know that Petey is also down there at this point, though her break-in later in the episode suggests that she suspects something is awry), she murmurs to herself, #quote[Oh, Mark. Are you all right?] + +This is a strange exhibition of affection, coming from the same woman who will throw a mug at Markfor his failure to #quote[get MDR to its numbers] as department chief, who knowingly subjects him to the break room-- which we observe on screen for the first time later in the episode-- and who steals the book left by his brother-in-law as a gift at his doorstep. +Despite these mistreatments, Cobel does still seem to hold some perverted penchant for and attachment to Mark. +As HaxDogma notes in #link("https://www.youtube.com/watch?v=JAhhVnevSm4")[his review of this episode], it is hard to see Mark's promotion to department chief after Petey disappears as anything other than a nepotistic appointment, given that Irving is clearly the more experienced refiner in a number of respects (orientation procedure, group photo protocol, number of years spent on the severed floor, to name a few). +Cobel's overinquisitive manner on display in this episode is perhap best described as motherly, even as she is certainly not a paradigmatically #emph[good] mother. + +There is also something undoubtedly sexual about Cobel's relationship to Mark. +Her lingering at the door in S1E2 waiting to be invited in, her awkward and suggestive mention of her late husband's building an apartment in the back of their abode in heaven #quote[in case I found a new man before I got there], her creating an excuse to talk to him by pretending to de-ice her stoop; and, naturally, her peeping at him through the window. +She is either a stalker by-the-book, or (more charitably) a lonely woman who is searching for some missing satisfaction. +Most likely, she is an inextricable concoction of the two. +Cobel wants to have Mark's cake and eat it too; to be at once his mother, his corporate superior, and (we can't help but suspect) his lover. +Like many put in positions of power, she has trouble setting her more inapproriate desires aside so as to simply 'do her job'. + += Primal father figures +Cobel's mother energy is arguably muted and mixed up in her #link("https://en.wikipedia.org/wiki/Sphinx#Riddle_of_the_Sphinx")[Sphinxesque] triplicity. +But the father energy on display in this episode is, by contrast, loudly and proudly pronounced in at least three different figures: Petey, Irving, and, of course, Kier Eagan. +#footnote[ + There is foreshadowing, too, of a fourth father figure in Rickon, Mark's brother-in-law. + While reading his confiscated book, Milchick quietly remarks to himself a thought that will become an important refrain for many other characters with respect to Rickon later in the season: #quote[This is… Jesus.] +] +Before tackling these fathers one by one, it is instructive to straightforwardly and schematically lay out the #strong[Oedipus complex], an 'absolute fiction' that nonetheless, Freud claims, depicts something foundational about the graph of the speaking subject, the graph in which we took interest in #link("./severance-ep-1.typ")[our analysis of S1E1]. + +The Oedipus complex is so-named because it takes its architecture from the figure of Oedipus as he appears in the ancient Greek playwright #link("https://www.cliffsnotes.com/literature/o/the-oedipus-trilogy/about-the-oedipus-trilogy")[Sophocles' trilogy], which consists of the plays #emph[Oedipus Rex], #emph[Oedipus at Colonus], and #emph[Antigone]. +(Oedipus' tragic tale is drawn from a mythology that predates these plays, but the story is nonetheless usually traced to its Sophoclean production.) +Oedipus is well-known to students of psychoanalysis because of Freud's making him into a #link("https://nosubject.com/Oedipus_complex")[complex], which is generally (mis)understood as 'every person wants to kill their father and fuck their mother'. +Famously, Oedipus killed his father-- at a crossroads, thinking he was simply a threatening stranger at the time-- and married his mother-- not understanding that relation in the moment of the act, either. + +Jacques Lacan rendered the Oedipus complex more philosophically significant than this overblown and crude Freudian telling. +For Lacan, the Oedipus complex designates an abstract account of how desire is produced by the speaking subject in relation to the formative figures with which it is in relation. +As he notes in one of his 1938 text, #emph[The Family Complexes]: + +#quote(block: true)[ +our criticism since Freud presents this psychological entity [the Oedipus complex] as #emph[the specific form of the human family] and subordinates all social variations of the family to it. @lacanFamilyComplexesFormation2002[p.35] +] + +The Oedipus complex is not so much a diagnosis of a particular perversion that is presumed universal, in the sense that everyone #emph[consciously] suffers by repressing these secret dual desires to kill (my father) and to fuck (my mother). +It is rather an important part of how he architects a philosophy of the subject's relation to itself (and others) by way of a #quote[triangular conflict] @lacanFamilyComplexesFormation2002[p.41] between three figures: one's self, the Mother, and the Father. + +The Mother is the subject's first known object that is seen as separable from one's sense of self. +We can imagine this through the process of weaning, of a mother teaching her baby that sustenance ought to be sought in solid foods rather than directly from her teat. +Originally, a baby does not have a firm enough sense of itself to recognize that the Mother's teat is separated from its own body. +When it wants nourishment, it cries, and a breast brimming with milk appears (assuming a good mother, here). +The breast seems almost part and parcel of the baby, from its perspective, as what reason does it have to think otherwise? +(We are assuming here that the separation between a baby's sense of its own body and the world is not ingrained at birth, but rather learned, acculturated.) +It is only when the baby's crying stops precipitating a breast that it should start to doubt this part of itself, to think that perhaps my Mother's breast is not part of #emph[me] as subject but rather its own kind of thing, a separate object. +Thus the Mother is, in this developmental sense, the subject's first #emph[proper] object. +The Mother (and her breast), the baby subject thinks, is both mine and not mine, as though there is some #emph[relation] that my Mother has to me, she is not (quite) the same as me. + +The Father, on the other hand, incorporates (into) the baby subject's sense of self differently. +It is not considered, as the Mother is, a part of the subject that was at some point taken away, but rather represents the source of that action of taking away. +If the Mother #emph[ought] (in the terms of the baby subject's nascent ethics) to be a part of me, the Father is the force and figure responsible for taking her away. +This stature of the Father is better understood, perhaps, with reference to the myth of the #strong[Primal Father], which Lacan reinterprets from its presentation in Freud as originally depicted in the fourth and final chapter of #emph[Totem and Taboo] @freudTotemTabooResemblances1919. +Like the Oedipus complex, the myth of the Primal Father is a narrativization that helps to understand the structure of the subject. +Suppose a primal horde, Freud offers, at the helm of which exists a Primal Father who monopolizes all women. +All women in the horde, in other words, are sexually subject to this single male; no other male gets to enjoy anything of them. +A band of brothers, resentful of the Father's monopoly on enjoyment, conspire to escape the ban on sexual enjoyment through a plot to murder him. +#footnote[ + There has been much written on Freud's mythos of the Primal Father. + For a relatively recent use of the concept that serves as a reasonable introduction to Lacan's reading of #emph[Totem and Taboo], see @mcgowanDistributionEnjoyment2021. +] +They do so through what could be called an original jealousy, a feeling that the Father is enjoying in a way that is prohibited (by virtue of the Father's taboo) for each of them. + +Freud offers this as an #quote[historic explanation… [of] the origin of incest] @freudTotemTabooResemblances1919[p.207], as the Primal Father's taboo on enjoyment is what, Freud suggests, drives exogamy, wherein each of the band of brothers leaves that original tribe to start their own in which they can (finally) enjoy the women for themselves. +That this is an historic explanation does not mean that Freud believes that it represents an actual state of affairs in some distant past. +Indeed, he states the opposite, that #quote[primal state of society has nowhere been observed.] @freudTotemTabooResemblances1919[p.233] +The parable of the Primal Father is historic rather in the sense that narrates to us an important aspect of the structure of the subject, much like Oedipus' tragedy. + += Daddy issues at work + +Okay: we now return from this Freudian digression to the stuff of #emph[Severance]. +What bearing do the Oedipus complex and the myth of the Primal Father have on the structure of the subject on display in the show? +Let's go now to the scene in S1E3 at the crossroads, where MDR runs into two employees in Optics and Design (O&D). + +#image("img/severance-s1e3-shot2.png") + +The composition of this shot puts the reflective axis down the center, and the encounter is suggestively Oedipean in its structure (at a crossroads, unknowing of the Other at play). +Note that Irving is compositionally mirrored by Burt, played by Christopher Walken, and we will explore this suggestive symmetry in detail in later episodes. +The two departments (MDR and O&D) know #emph[of] each other, we surmise from the dialogue that follows. +But Irving isn't supposed to know Burt by name, as he accidentally happened upon him in S1E2 on the way to a Wellness session. +(Burt was coming #emph[from] his Wellness session.) + +While Irving greets Burt on the back of this previous encounter with gentle and flirtatious warmth, Dylan's hostility towards O&D is clear. +In place of the camaraderie that one might have hoped for between the two factions given their shared plight as severed workers, there appears to be an enmity built on a mythology (what Irving calls an #quote[absolute fiction]) of otherness: + +#quote(block: true)[ + Kier sorted the departments by virtue. + Macrodats are clever and true, while O&D's more cruelty-centered…. + O&D tried a violent coup on the others decades ago, and that's why they reduced them down to two. + And that's why they keep us all so far apart now. +] + +Kier is evidently the Primal Father of the severed floor, responsible for instituting the symbolic system of rules, regulations, and affects in the various 'bands of brothers' which reside there. +The tour of the perfect replica of Kier's house later in the episode reinforces his architectural status as Primal Father. +Irving chides Mark for his lack of reverence in deigning to turn the tour of the Perpetuity Wing into Eagan Bingo, and is aghast when he almost happens to #quote[bed sit] on the facsimile in his duplicate chambers. +(Thou shall not lie in Kier Eagan's bed.) +Kier and the lineage of Eagans more generally constitute the #link("https://nosubject.com/Law_of_the_father")[law of the father], the signifier of authority that keeps the severed floor's social order intact, the symbolic source from which both rules and the forbidden temptations of their being broken, taboos, sprout. Irving fosters this authority during the tour, standing in for the absent caregivers, existential (Kier, the Eagans) and material (Cobel and Milchick as superintendents who seem to be letting the kids take care of themselves for a short period). + +Another paternal authority whose absence has haunted and structured Mark since the show's opening is Petey, the man whose shoes he stepped into as MDR's department chief. +As per his exchange to Cobel in the mug-throwing scene, Mark lionizes Petey as a tone-setter, often acting through an ethics refracted by the subordinate conjunctive, 'if Petey were here', or the preface 'Petey used to say'. +Mark's innie is steered more by an imagined sense of what Petey would do, rather than what Kier would. + +Thus while it is Cobel who is explicitly in charge, the spectral presence of these father figures-- Kier, Petey, Irving-- correlatively structures the subject on the severed floor. +There is, in other words, an Oedipal triangular conflict at work in relation the ethical imperative of a severed worker. +The four members of MDR, as orientations to the structure of this subject, suffer different relationships to the positions of Mother and Father. +Mark S is a momma's boy, sired more by Petey's radical rejection of company policy than by Kier. +Dylan, though impertinent to the minutiae in the structure of Law at times, is ultimately his Father's son, acquiring satisfaction by accumulating accolades, and apparently driven by the impending idea of another finger trap or a waffle party. +Irving seems at this point the most mature of the children, looking reverentailly to Kier. +Yet recall that he has been chided by Milchick already for falling asleep on the job, so not all is perfect in paradise. +Hellie has no time for Cobel's authority, yet we will see in due course that her relationship with a Father is a deep lineament in her personality, too. + += Taming tempers +The count of four in the members of MDR mirrors the exact amount of tempers that we learn about from Kier Eagan's wax simulacrum speaking during the tour of the Perpetuity Wing. +These tempers are crucial as coordinates of the Eaganic attempt to coherently quantify the subject, and Kier's pronouncement is deeply significant for our investigation of the subject's distorted structure on the severed floor: + +#quote(block: true)[ + I know that death is near upon me, because people have begun to ask what I see as my life's great achievement. + They wish to know how they should remember me as I rot. + In my life, I have identified four components, which I call tempers, from which are derived every human soul. + #strong[Woe. Frolic. Dread. Malice]. + Each man's character is defined by the precise ratio that resides in him. + I walked into the cave of my own mind, and there I tamed them. + Should you tame the tempers as I did mine, then the world shall become but your appendage. + It is this great and consecrated power that I hope to pass on to all of you, my children. +] + +If there was any doubt that Kier Eagan embodies the Freudian Primal Father, the foundational component of absolute fiction on which the edifice of Law (the rules and taboos by which a subject is bound to abide) is constructed, the quotation above should put it to bed. +Kier's 'philosophy' seeks to conquer death by quantifying life, sorting its myriadic nature into a #quote[precise ratio] of character that can be counted (completely, it seems) in four distinct tempers. +Indeed, we saw the pictorial representation of this taming in s1e2, in the scene where Irving meets Burt: + +#image("img/severance-s1e3-tamingtempers.png") + +In the post-Platonic cave of his own mind, Kier is the master of his passions. +He admits no unconscious contours that sneak up on him unbeknowst in Freudian slips of the tongue or unwanted symptoms. +Indeed, the Eaganesque fantasy of the subject is one in which the necessary excess of language that psychoanalysis discovered does not exist. +Words are detected (via sensors in the elevator, say), controlled, managed. +Any psychoanalytic excess is, in Kier's project of a precisely rationalized subject, beaten out of language. +Excess meaning is 'tamed' as if it were a wild animal by a clear-headed, upstanding, divinely radiant visonary. +(As we will see, the position of primal power that Kier occupies here is sexually overbearing, too, as we might suspect from the Freudian analogy.) + +This episode ends with two scenes depicting the dark and bloody underside of Kier's waxen vision of the precisely quantified human subject. +The first is Helly's harrowing experience in the break room, a space where the unruly distance between words as they are uttered and the meaning they convey is thought to be stamped out, suffocated by the drudgery of debilitating repetition. +A subject will not exceed its authorized symbolization, the break room seems to want to claim. +The worker's unconscious will be tamed and ultimately made beholden to a regime of conscious rationality. +The second, and the closing scene of the epsiode, is Petey's psychotic demise at the convenience store, where he yells at wit's end: #quote[I need tokens so I can eat!] +Ravaged by the failure of his complete quantification inside Lumon, Petey seems no longer to have a firm footing in either his innie's or outie's reality. +Mark looks on from a distance as he collapses outside the store, escorted by police, attempting (it seems) to account for his disintegration. + +#bibliography("./references.bib", style: "chicago-author-date") diff --git a/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/writing-in-typst.typ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/writing-in-typst.typ new file mode 100644 index 0000000..1861235 --- /dev/null +++ b/crates/tests/store/examples_slashblog_site_slashcontent_slashseverance_minusep_minus1_full_stoptyp/writing-in-typst.typ @@ -0,0 +1,132 @@ +// @rheo:test +// @rheo:formats html +// @rheo:description Blog post with HTML video elements and custom functions + +#let video(path, width: "auto", height: "auto", controls: "true", autoplay: "false", loop: "false") = { + html.elem("video", attrs: ( + src: path, + width: width, + height: height, + controls: controls, + autoplay: autoplay, + loop: loop, + style: "max-width: 100%", + )) +} + += Writing in Typst | Hacking on Neovim with Claude +== What is a 'good' writing system? +I have been #link("https://www.ohrg.org/devonthink-part-i")[incrementally] #link("https://www.ohrg.org/devonthink-part-ii")[hacking] #link("https://www.ohrg.org/devonthink-part-iii")[on] my writing environment for some time now, since at least 2013 when I started seriously using computers in undergrad. +A couple of years ago, I migrated to Orgmode as the best markup syntax for my needs, and #link("https://www.ohrg.org/writing-setup")[wrote aa post about how Emacs and Orgmode serviced my writing needs]. + +Here's a summary of that post and the core tenets of what I consider an acceptable writing environment, parsed out over the five or so years I've been experimenting with one through grad school: + ++ *Flexible, powerful and distraction-free*. + In short, this means that the environment needs to be an extension to a modal editor in the terminal. + I started using a #link("https://carlosbecker.com/posts/ed/")[modal text editor] around 2018, and use a range of ergonomic keyboards in #link("https://www.ohrg.org/cycling-typing")[funky ways] that make using a mouse undesirable in most cases. + (The web browser is the one environment where I still get some mileage out of a mouse. + I do a lot with keyboard shorcuts via #link("https://vimium.github.io/")[Vimium], but there are still some contexts where it's just quicker or more comfortable to use a mouse.) + One of the main reasons that I settled on Orgmode rather than, say, Markdown at the time was because of its #link("https://orgmode.org/manual/Citations.html")[more standardized bibliographic management]. + ++ *Non-proprietary and sane markup format*. + Microsoft Word documents and Google Docs are great for a lot of things, but I refuse to rely on either of them as a primary format for all of the writing I do, as their formats are to hard to parse (to write custom software for) and bound to Microsoft's and Google's ecosystems respectively. + The ability to run Unix-style comands on a simple markup format from a terminal to search and replace, for example, is an essential. + Writing documents in a plain-text markup language also gives me the safety of knowing that, if it really came down to it, I could write my own parser and compilers. + My writing archive shouldn't strictly rely on some company's infrastructure to host, search, or otherwise make use of the thought it contains. + Using such a format also means that cross-platform editing is made simpler and possible. + (I run linux mostly, but still regrettably use Android as my phone's operating system.) + ++ *Multi-format export*. + #link("https://willcrichton.net/notes/portable-epubs/")[Most of the world's documents are still PDF]. + There's no getting away from needing to export writing as PDF in many cases-- for e-readers like #link("https://www.ohrg.org/using-two-remarkables")[the reMarkable that I use], or for submission to conferences. + But we increasingly read writing on a web page of some sort, and so I also need a workflow to export fully functional documents to HTML and CSS, too. + Other formats that are interesting if not essential include some kind of presentation file (PowerPoint, or better: just a website that has slideshow-like interactions), Markdown for rich formatting to copy somewhere, and plain text. + +I have up until very recently used Orgmode as my markup language of choice, exported them to PDF with exported them to PDF with #link("https://www.latex-project.org/")[latex], and exported them to HTML with #link("https://pandoc.org/")[pandoc]. +But I am very attached to the Neovim ecosystem for my code editing and writing, and so it was clunky to open up an Emacs installation (that I barely understood) exclusively to edit Orgmode. +So I switched to editing Orgmode in Neovim along with everything else, #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[using plugins] and #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua#L765-L990")[custom functions] to get towards the writing experience that I wanted. + +This has actually worked surprisingly well, but it has some sharp edges. +One of the more significant ones is that any time I want to produce anything more complicated than basic, formatted text with citations and footnotes-- for all of which pandoc transformations produce reasonable output in both HTML and PDF-- I need to start embedding LaTeX into Orgmode, and deal with the LaTeX toolchain / dependency management in order to compile a PDF. +Similarly, if I want to produce an interactive HTML document, I need to embed the source code directly in Orgmode and ensure that the export process handles dependencies and the like appropriately. + +Some of this is unavoidable. +If I want to run custom Javascript in a website that is well beyond the expressive capacities of a markup language, at some point I just want to be able to write Javascript. +But what I found frustrating about my Orgmode / LaTeX / HTML workflow is that there wasn't any reasonable way to work towards extending the markup language in _some_ ways, unless I was willing to start developing my own bespoke flavor of Orgmode plus plus. +I also don't particularly like wrestling with the LaTeX ecosystem, because-- and this is hardly controversial to say-- #link("https://tex.stackexchange.com/questions/222500/why-is-latex-so-complicated")[LaTeX has a lot of bloat]. +What I wanted was a more _extensible_ system which had saner defaults. + +== Enter: Typst + +A few months ago, I started seriously considering #link("https://typst.app/")[typst] as a potential replacement for LaTeX. +At the very least, I thought, it would be more fun to wrestle with a modern ecosystem when struggling to produce some custom table or figure in my output PDF, as typst has a #link("https://typst.app/docs/reference/layout/")[layout system] that uses terms that are a lot more intuitive to me than the black magic of laying out LaTeX documents. + +It just so happened, however, that I started to follow typst development more closely at a time when the final touches to the #link("https://github.com/typst/typst/issues/5512")[basic foundations of HTML export], such as footnotes and bibliography, were just about to be added to the upstream. +So I made #link("https://github.com/typst/typst/pulls?q=is%3Apr+author%3Abreezykermo+is%3Aclosed")[a few contributions] to spirit it along, and started more serious experimentation using typst as a unified way to produce _both_ PDF and HTML in my writing environment. +Pandoc #link("https://pandoc.org/MANUAL.html#typst")[can convert to and from typst], so I originally intended to keep writing documents in Orgmode and then transiently convert them to typst in order to produce PDF and HTML both. +But I quickly found that the typst syntax natively accommodates all of the features that I make use of regularly in Orgmode such as citations, footnotes, headings, links and text decoration-- and then some. + +So why not write my blogs, papers, and documents directly in typst? +I considered the critical features of my Neovim / Orgmode writing environment that I didn't want to abandon: + ++ *Shortcuts for markup*. + The #link("https://github.com/nvim-orgmode/orgmode")[nvim-orgmode plugin] makes writing Orgmode in Neovim pleasurable, providing shortcuts to insert a link and basic text decoration while composing. ++ *Citation and link picking*. + Though I've gone without it for a few months for reasons that are immaterial here, I used to have a shortcut to bring up a fuzzy finder for all of my bibliography entries to easily insert a citation. + The same fuzzy finder would make it easy to link to local files (in a website, for example, to link to other posts). ++ *Document folding*. + The ability to fold away all of the text beneath a heading is very useful when navigating larger documents, as it helps me to compartmentalize writing tasks and organize longer documents such as a dissertation chapter. ++ *Export shortcuts*. + I have customized my Neovim editor so that I can easily export the active Orgmode document (through the pandoc and LaTeX processes described above). + Personally, I don't feel that I need a real-time live preview of the document as I type, as I generally just want to check that it looks reasonable at certain junctures in the writing process, rather than continuously. + +The one other features of Orgmode that I have come to rely on heavily is its #link("https://orgmode.org/manual/TODO-Basics.html")[TODO functionality]. +I typically only use this in notes related to projects or tasks more generally, however, and not in documents that are intended for publication such as a paper or blog post. + +== Enter: Claude Code +At this point in the past of a new writing technology's prospecting, I would go searching for a Neovim plugin for typst and hope that it provides features that satisfy a majority of these requirements. +I've spent a fair bit of time #link("https://github.com/breezykermo/nixos/blob/f79c84baa8433767189c9d7b434137ba80c63531/home-manager/server/neovim/init.lua")[tinkering with my init.lua], the entrypoint for customizing Neovim, but I've never had the time nor interest to sit down and write a plugin from scratch. + +LLMs, of course, are at time of writing taking the coding world by storm. +I have started moderately relying on #link("https://github.com/anthropics/claude-code")[Claude Code] when writing some-- though certainly not all-- kinds of code. +As is well-known by now, Claude is especially good at scaffolding hacky scripts or modules from scratch, when no large codebase or domain-specific knowledge needs to be kept in context. +A Neovim plugin, I realized this morning, is a pretty ideal domain for LLM-assisted coding. +The 'codebase' is often just a single configuration file, and the domain-specific knowledge is the Neovim editor itself, a well-documented and expansively customized software for which there are many examples on Reddit.#footnote[It's impossible to mention LLM coding at this time without adding some sort of disclaimer that, no, I don't think AGI is around the corner, and yes, I do expect both programming languages and language writ large to remain 'a thing' in the foreseeable future. LLMs are an incredibly powerful tool to write and analyze code and text, but the purpose of code and text-- as a medium of symbolic communication amongst social beings-- has not been rendered valueless since ChatGPT became publically available. If anything, the value of adeptly and adroitly handling written language has taken deeper root. For my preliminary thoughts on why we are so keen to imagine that computers will supplant the usefulness of the human, I refer the reader to #link("https://caiml.org/dighum/announcements/digital-humanism-salon-capital-and-the-computer-by-lachlan-kermode-2024-06-24/")[this talk I gave in 2024].] + +So I fired up Claude Code earlier this afternoon, and-- fast-forward an hour or two-- I have a fully functional writing environment for Typst that essentially has feature-parity with my Orgmode environment. +Moreover, my #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim")[Neovim config] is now much more comprehensibly modularized; and I have a tried-and-tested method for extending it without needing to spend days learning the ins-and-outs of Neovim's API; and #link("https://github.com/breezykermo/nixos/commit/67cdbbae0dd77db766289b7f6eb278091ab937dd")[some bugbears in my NixOS config were eliminated] while I was at it. +(If that last bit means nothing to you, count yourself lucky!) + +== My new writing environment +I use #link("https://tree-sitter.github.io/tree-sitter/")[treesitter] for syntax highlighting, and Typst already looks pretty good with it. +I get function completion #link("https://github.com/breezykermo/nixos/blob/main/home-manager/server/neovim/lua/plugins/lsp.lua#L17-L24")[by integrating an LSP for the format], for which I'm using #link("https://github.com/Myriad-Dreamin/tinymist")[tinymist]. + +As I noted above, I haven't had dynamic link or citation insertion for some time. +It was one of the features that got lost in my move from writing Orgmode in Emacs to writing it in Neovim. +I use #link("https://github.com/nvim-telescope/telescope.nvim")[telescope.nvim] for general search and file-picking when coding in Neovim, and I figured that I could use a customized pop-up to dynamically pick available citations from the relevant #link("https://www.overleaf.com/learn/latex/Bibliography_management_with_bibtex")[BibTeX] file, too. +After a few minutes of #link("https://simonwillison.net/2025/Oct/7/vibe-engineering/")[vibe-engineering], I have the following: + +#video("../img/typst-links-citations-demo.mp4") + +When I am writing in Typst, and I want to bring in a reference, I can open a panel. +Note that the search is full-text, not just using the reference ID. +I also have a shortcut to specify which bib file to use through the `#bibliography` function in Typst. +I can insert links in the same way as citations, both references files relative to the current one (blog posts on the same site), and external links. +Both the citation and link insertion work either by highlighting text and annotating it, or to insert new links/citations. +I also have a similar shortcut to add footnotes. + +This is pretty functional now for generic writing! + +== Future work +Typst isn't ideal for producing fully-featured websites currently, as HTML export is experimental. +Even when it becomes better supported, the project is-- understandably, given its priority supporting PDF-- taking a #link("https://github.com/typst/typst/issues/5512")[relatively conservative approach] to HTML generation. +Anything that doesn't have a robust analog in a PDF document, such as videos and hover panels, will have to be 'embedded' in Typst with HTML/CSS/JS, rather than being written in Typst syntax. +The current experience isn't much worse than Orgmode with Pandoc, though, and the Typst roadmap promises that it will become much better in the relatively short-term future. + +There is a longstanding issue that I've had with links in Orgmode that I haven't yet tackled with Typst. +When I'm writing, I like hyperlinked text to appear as it will in the final document, i.e. without the underlying URL on display. +When editing any particular line, though, it's better that all of the links are 'expanded' to their full source syntax (`#link("...")[...]`) so that its feasible to edit the markup without requiring any fancy shortcuts. +The effective shortening of lines that occurs when hiding these URLS results in different Neovim line-wrapping requirements, with which the Orgmode plugin I have been using does a bad job, giving ugly linebreaks in documents with long links. +This link presentation will likely be the next feature I add to my Neovim Typst plugin. + +I'll add to the capabilities in #link("https://github.com/breezykermo/nixos/tree/main/home-manager/server/neovim/lua/typst")[my Neovim config files], and might eventually release a separate plugin if the features become significant/mature enough. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/code_examples.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/code_examples.typ new file mode 100644 index 0000000..1b7df43 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/code_examples.typ @@ -0,0 +1,27 @@ += Code Blocks with Links Test + +This document tests that link transformation correctly handles code blocks. + +== Real Links + +Real links should be transformed: #link("./other.typ")[see other page]. + +Multiple links: #link("./intro.typ")[intro] and #link("./conclusion.typ")[conclusion]. + +== Code Examples + +Inline code should preserve links: `#link("./file.typ")[example]`. + +Code block example: +``` +// This link should be preserved: +#link("./other.typ")[other page] +``` + +== Mixed Content + +Real link: #link("./chapter1.typ")[Chapter 1] + +Then code: `#link("./code.typ")[code link]` + +And another real link: #link("./chapter2.typ")[Chapter 2] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/rheo.toml new file mode 100644 index 0000000..6302c40 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/code_blocks_with_links/rheo.toml @@ -0,0 +1,4 @@ +version = "0.2.0" + +# Test PDF transformation +formats = ["pdf"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/appendix/notes.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/appendix/notes.typ new file mode 100644 index 0000000..9457824 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/appendix/notes.typ @@ -0,0 +1,13 @@ += Appendix: Notes + +Additional notes and references. + +== Cross References + +Back to #link("../chapters/ch1.typ")[Chapter 1]. + +Return to #link("../intro.typ")[the introduction]. + +== Details + +Testing links from a different subdirectory. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch1.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch1.typ new file mode 100644 index 0000000..b62d95b --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch1.typ @@ -0,0 +1,11 @@ += Chapter 1 + +This is the first chapter. + +== References + +Go back to #link("../intro.typ")[the introduction]. + +Continue to #link("ch2.typ")[Chapter 2] (sibling). + +See #link("../appendix/notes.typ")[the appendix notes] for additional info. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch2.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch2.typ new file mode 100644 index 0000000..0bd8187 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/chapters/ch2.typ @@ -0,0 +1,13 @@ += Chapter 2 + +This is the second chapter. + +== Navigation + +Previous: #link("ch1.typ")[Chapter 1] + +Root: #link("../intro.typ")[Introduction] + +== Content + +Testing cross-directory navigation patterns. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/intro.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/intro.typ new file mode 100644 index 0000000..bdabdac --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/intro.typ @@ -0,0 +1,9 @@ += Introduction + +Welcome to the cross-directory test. + +== Overview + +This document links to #link("chapters/ch1.typ")[Chapter 1]. + +See also #link("chapters/ch2.typ")[Chapter 2] for more details. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/rheo.toml new file mode 100644 index 0000000..61b6bec --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/cross_directory_links/rheo.toml @@ -0,0 +1,8 @@ +version = "0.2.0" + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Cross Directory Test" +vertebrae = ["intro.typ", "chapters/ch1.typ", "chapters/ch2.typ", "appendix/notes.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/chapter.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/chapter.typ new file mode 100644 index 0000000..236b6df --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/chapter.typ @@ -0,0 +1,5 @@ +#set document(title: [Main Chapter]) + += Chapter 1 + +The main content. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/intro.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/intro.typ new file mode 100644 index 0000000..e917334 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/intro.typ @@ -0,0 +1,5 @@ +#set document(title: [Introduction]) + += Introduction + +Welcome to the book. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/rheo.toml new file mode 100644 index 0000000..c6bc8cb --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_explicit_spine/rheo.toml @@ -0,0 +1,5 @@ +version = "0.2.0" + +[epub.spine] +title = "My EPUB Book" +vertebrae = ["intro.typ", "chapter.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/a.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/a.typ new file mode 100644 index 0000000..e01171a --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/a.typ @@ -0,0 +1,5 @@ +#set document(title: "Part A") + += Part A + +This is the first part of the document. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/b.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/b.typ new file mode 100644 index 0000000..7f0dea7 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/b.typ @@ -0,0 +1,5 @@ +#set document(title: "Part B") + += Part B + +This is the second part of the document. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/c.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/c.typ new file mode 100644 index 0000000..3941877 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/epub_inferred_spine/c.typ @@ -0,0 +1,5 @@ +#set document(title: "Part C") + += Part C + +This is the third part of the document. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/array_index_error.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/array_index_error.typ new file mode 100644 index 0000000..cb27669 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/array_index_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "array_index_error.typ", "│" +// @rheo:formats pdf +// Test file with array index error + += Array Index Error Test + +// Create a small array +#let items = ("first", "second", "third") + +// Try to access an index that doesn't exist +#items.at(10) + +Some content diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/function_arg_error.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/function_arg_error.typ new file mode 100644 index 0000000..396014b --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/function_arg_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "function_arg_error.typ", "│" +// @rheo:formats pdf +// Test file with function argument error + += Function Argument Error Test + +// Define a function that requires two arguments +#let add_numbers(x, y) = x + y + +// Call it with only one argument (missing required argument) +#add_numbers(5) + +Some content diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/import_error.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/import_error.typ new file mode 100644 index 0000000..ca1820c --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/import_error.typ @@ -0,0 +1,12 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "import_error.typ", "│" +// @rheo:formats pdf +// Test file with import error (missing file) + += Import Error Test + +// Try to include a file that doesn't exist +#include "nonexistent_file.typ" + +Some content diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_field.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_field.typ new file mode 100644 index 0000000..53a0410 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_field.typ @@ -0,0 +1,18 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "invalid_field.typ", "│" +// @rheo:formats pdf +// Test file with invalid field access + += Invalid Field Access Test + +// Create a dictionary and try to access non-existent field +#let person = ( + name: "Alice", + age: 30 +) + +// Try to access a field that doesn't exist +#person.nonexistent_field + +Some content diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_method.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_method.typ new file mode 100644 index 0000000..9b634d3 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/invalid_method.typ @@ -0,0 +1,13 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "invalid_method.typ", "│" +// @rheo:formats pdf +// Test file with invalid method call + += Invalid Method Test + +// Try to call a method that doesn't exist on strings +#let text = "hello" +#let result = text.nonexistent_method() + +Some content diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/multiple_errors.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/multiple_errors.typ new file mode 100644 index 0000000..63b4c70 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/multiple_errors.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "multiple_errors.typ", "│" +// @rheo:formats pdf +// Test file with multiple errors + += Multiple Errors Test + +// First error: undefined variable +#let x = undefined_var_one + +// Second error: type mismatch +#let y = 5 + "string" + +// Third error: undefined variable again +The value is: #undefined_var_two diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/rheo.toml new file mode 100644 index 0000000..4b4e51e --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/rheo.toml @@ -0,0 +1,7 @@ +version = "0.2.0" + +# Test project for error formatting validation +formats = ["pdf"] + +[pdf] +# Don't merge - test single file errors diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/syntax_error.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/syntax_error.typ new file mode 100644 index 0000000..4dd311e --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/syntax_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "syntax_error.typ", "│" +// @rheo:formats pdf +// Test file with syntax error (unclosed delimiter) + += Syntax Error Test + +#let items = [ + Item 1, + Item 2, + Item 3 +// Missing closing bracket ] + +Content follows diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/type_error.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/type_error.typ new file mode 100644 index 0000000..01aa6cd --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/type_error.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "type_error.typ", "│" +// @rheo:formats pdf +// Test file with type error +// This should trigger a Typst compilation error + += Type Error Test + +#let x = 5 +#let y = "hello" + +// This will cause a type error: can't add number and string +#let result = x + y + +Content: #result diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/undefined_var.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/undefined_var.typ new file mode 100644 index 0000000..e73bbae --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/undefined_var.typ @@ -0,0 +1,10 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "undefined_var.typ", "undefined_variable", "│" +// @rheo:formats pdf +// Test file with undefined variable error + += Undefined Variable Test + +// This will cause an error: undefined_variable doesn't exist +The value is: #undefined_variable diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/unknown_function.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/unknown_function.typ new file mode 100644 index 0000000..0d3e287 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/error_formatting/unknown_function.typ @@ -0,0 +1,12 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "unknown_function.typ", "│" +// @rheo:formats pdf +// Test file with unknown function call + += Unknown Function Test + +// Call a function that doesn't exist +#nonexistent_function("arg1", "arg2") + +Some content here diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/about.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/about.typ new file mode 100644 index 0000000..1e9f1c4 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/about.typ @@ -0,0 +1,5 @@ +#set document(title: [About]) + += About + +Information about the site. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/index.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/index.typ new file mode 100644 index 0000000..f041468 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/index.typ @@ -0,0 +1,7 @@ +#set document(title: [Home]) + += Welcome + +This is the home page. + +See also: @about diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/rheo.toml new file mode 100644 index 0000000..cad360b --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/html_spine/rheo.toml @@ -0,0 +1,5 @@ +version = "0.2.0" + +[html.spine] +title = "My Website" +vertebrae = ["index.typ", "about.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/chapter-01.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/chapter-01.typ new file mode 100644 index 0000000..90d0f99 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/chapter-01.typ @@ -0,0 +1,5 @@ += Chapter 01 + +This filename contains numbers. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file-name.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file-name.typ new file mode 100644 index 0000000..3fd2139 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file-name.typ @@ -0,0 +1,5 @@ += File with Hyphen + +This filename contains a hyphen. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file_name.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file_name.typ new file mode 100644 index 0000000..b786beb --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/file_name.typ @@ -0,0 +1,5 @@ += File with Underscore + +This filename contains an underscore. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/main.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/main.typ new file mode 100644 index 0000000..b557e5f --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/main.typ @@ -0,0 +1,17 @@ += Path Edge Cases Test + +This tests unusual but valid filename patterns. + +== Links to Edge Case Files + +Hyphen: #link("file-name.typ")[file with hyphen] + +Underscore: #link("file_name.typ")[file with underscore] + +Dot in name: #link("version-1.0.typ")[file with dot] + +Number: #link("chapter-01.typ")[file with number] + +== Content + +All these edge cases should transform correctly. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/rheo.toml new file mode 100644 index 0000000..f6d36e5 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/rheo.toml @@ -0,0 +1,8 @@ +version = "0.2.0" + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Path Edge Cases Test" +vertebrae = ["main.typ", "file-name.typ", "file_name.typ", "version-1.0.typ", "chapter-01.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/version-1.0.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/version-1.0.typ new file mode 100644 index 0000000..7fc2556 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_path_edge_cases/version-1.0.typ @@ -0,0 +1,5 @@ += Version 1.0 + +This filename contains a dot in the name (not just the extension). + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc1.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc1.typ new file mode 100644 index 0000000..69c2f08 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc1.typ @@ -0,0 +1,12 @@ +// @test-formats: pdf,html,epub +// @test-description: Verify AST-based .typ link transformation + += Document 1 + +This is the first document. + +You can navigate to #link("./doc2.typ")[See Doc 2] for more information. + +== Section in Doc 1 + +More content here. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc2.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc2.typ new file mode 100644 index 0000000..5925102 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/doc2.typ @@ -0,0 +1,9 @@ += Document 2 + +This is the second document. + +Go #link("./doc1.typ")[Back to Doc 1] to see the first document. + +== Another Section + +Additional content in document 2. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/rheo.toml new file mode 100644 index 0000000..d3116e9 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/link_transformation/rheo.toml @@ -0,0 +1,10 @@ +version = "0.2.0" + +[pdf.spine] +merge = true +title = "Link Transformation Test" +vertebrae = ["doc1.typ", "doc2.typ"] + +[epub.spine] +title = "Link Transformation Test" +vertebrae = ["doc1.typ", "doc2.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page1.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page1.typ new file mode 100644 index 0000000..9164d4d --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page1.typ @@ -0,0 +1,11 @@ += Page 1 + +This is the first page. + +See the #link("./page2.typ#intro")[introduction in Page 2] for details. + +Also check #link("./page2.typ#conclusion")[the conclusion]. + +== Section in Page 1 + +More content here. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page2.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page2.typ new file mode 100644 index 0000000..82d874d --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/page2.typ @@ -0,0 +1,19 @@ += Page 2 + +This is the second page. + +== Introduction + +This is the introduction section. + +It has some content that the first page links to. + +== Middle Section + +Some middle content. + +== Conclusion + +This is the conclusion section. + +Referenced from page 1. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/rheo.toml new file mode 100644 index 0000000..38e80ac --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/links_with_fragments/rheo.toml @@ -0,0 +1,12 @@ +version = "0.2.0" + +formats = ["html", "pdf", "epub"] + +[pdf.spine] +merge = true +title = "Links with Fragments Test" +vertebrae = ["page1.typ", "page2.typ"] + +[epub.spine] +title = "Links with Fragments Test" +vertebrae = ["page1.typ", "page2.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/multiple_links_inline.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/multiple_links_inline.typ new file mode 100644 index 0000000..cf1189a --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/multiple_links_inline.typ @@ -0,0 +1,21 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Multiple links per line test + += Multiple Links Test + +== Adjacent Links with Text + +See #link("file1.typ")[File 1] and #link("file2.typ")[File 2] for details. + +== Multiple References in List + +References: #link("a.typ")[A], #link("b.typ")[B], #link("c.typ")[C]. + +== Minimal Separation + +Adjacent links: #link("x.typ")[X]#link("y.typ")[Y] + +== Multiple Links in Sentence + +Check #link("intro.typ")[the introduction], then #link("chapter1.typ")[chapter 1], and finally #link("conclusion.typ")[the conclusion]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter1.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter1.typ new file mode 100644 index 0000000..7b27ae2 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter1.typ @@ -0,0 +1,7 @@ +#set document(title: "Chapter 1") + += Chapter 1 + +This is the first chapter. + +See also: #link("./chapter2.typ")[Chapter 2] for more information. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter2.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter2.typ new file mode 100644 index 0000000..a41be40 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/chapter2.typ @@ -0,0 +1,7 @@ +#set document(title: "Chapter 2") + += Chapter 2 + +This is the second chapter. + +Refer back to #link("./chapter1.typ")[Chapter 1] if needed. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/rheo.toml new file mode 100644 index 0000000..d388837 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_individual/rheo.toml @@ -0,0 +1,3 @@ +version = "0.2.0" + +formats = ["pdf"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter1.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter1.typ new file mode 100644 index 0000000..0241079 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter1.typ @@ -0,0 +1,5 @@ += Chapter 1 + +First chapter content. + +See also #link("./chapter2.typ")[Chapter 2]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter2.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter2.typ new file mode 100644 index 0000000..0cb7bf5 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/chapter2.typ @@ -0,0 +1,7 @@ += Chapter 2 + +Second chapter content. + +Refer back to #link("./chapter1.typ")[Chapter 1]. + +Jump to #link("./conclusion.typ")[Conclusion]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/conclusion.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/conclusion.typ new file mode 100644 index 0000000..28e2cf2 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/conclusion.typ @@ -0,0 +1,3 @@ += Conclusion + +Final thoughts. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/intro.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/intro.typ new file mode 100644 index 0000000..f21fd34 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/intro.typ @@ -0,0 +1,5 @@ += Introduction + +This is the introduction. + +Continue to #link("./chapter1.typ")[Chapter 1]. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/rheo.toml new file mode 100644 index 0000000..716eb45 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" + +[pdf.spine] +merge = true +vertebrae = ["intro.typ", "chapter*.typ", "conclusion.typ"] +title = "Test Merged Document" diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/a.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/a.typ new file mode 100644 index 0000000..371d2ea --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/a.typ @@ -0,0 +1,5 @@ +#set document(title: "doc1") + += A + +The first doc. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/b.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/b.typ new file mode 100644 index 0000000..05b0ba0 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/b.typ @@ -0,0 +1,6 @@ +#set document(title: "B") + += B + +THIS IS A DRAFT, DO NOT RENDER. + diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/c.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/c.typ new file mode 100644 index 0000000..f8931d9 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/c.typ @@ -0,0 +1,5 @@ +#set document(title: "doc2") + += C + +The second doc. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/rheo.toml new file mode 100644 index 0000000..68d5f34 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_merge_false/rheo.toml @@ -0,0 +1,16 @@ +version = "0.2.0" + +formats = ["pdf", "html"] + +[pdf.spine] +vertebrae = [ + "a.typ", + "c.typ" +] +merge = false + +[html.spine] +vertebrae = [ + "a.typ", + "c.typ" +] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file1.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file1.typ new file mode 100644 index 0000000..6e6e5e5 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file1.typ @@ -0,0 +1,5 @@ +#set document(title: [File 1]) + += Chapter 1 + +This is the first file. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file2.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file2.typ new file mode 100644 index 0000000..4cec706 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/file2.typ @@ -0,0 +1,5 @@ +#set document(title: [File 2]) + += Chapter 2 + +This is the second file. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/rheo.toml new file mode 100644 index 0000000..8bf9dab --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/pdf_spine_merge_false/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" + +[pdf.spine] +title = "Individual PDFs" +vertebrae = ["file*.typ"] +merge = false diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/rheo.toml new file mode 100644 index 0000000..e7d4015 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/rheo.toml @@ -0,0 +1,8 @@ +version = "0.2.0" + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Relative Path Test" +vertebrae = ["root.typ", "subdir/child.typ", "subdir/sibling.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/root.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/root.typ new file mode 100644 index 0000000..4ad9a58 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/root.typ @@ -0,0 +1,13 @@ += Root Document + +This is the root of the test project. + +== Links to Subdirectory + +See #link("subdir/child.typ")[the child document] in the subdir. + +Also check out #link("subdir/sibling.typ")[the sibling]. + +== More Content + +This tests that subdirectory paths transform correctly. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/child.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/child.typ new file mode 100644 index 0000000..ed0437a --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/child.typ @@ -0,0 +1,15 @@ += Child Document + +This document is in a subdirectory. + +== Link to Parent Directory + +Go back to #link("../root.typ")[the root document]. + +== Link to Sibling (Explicit Same Dir) + +See #link("./sibling.typ")[the sibling] in the same directory. + +== Link to Sibling (Implicit Same Dir) + +Also see #link("sibling.typ")[the sibling again] with implicit path. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/sibling.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/sibling.typ new file mode 100644 index 0000000..b1ad7a1 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/relative_path_links/subdir/sibling.typ @@ -0,0 +1,15 @@ += Sibling Document + +This is the sibling document in the subdirectory. + +== Link to Sibling + +Go to #link("child.typ")[the child document]. + +== Link to Parent Directory + +Return to #link("../root.typ")[root]. + +== Content + +Testing various relative path patterns. diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function/main.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function/main.typ new file mode 100644 index 0000000..7486fea --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function/main.typ @@ -0,0 +1,24 @@ +// @rheo:test +// @rheo:formats html,pdf,epub +// @rheo:description Verifies target() function returns correct format string + += Target Function Test + +This test verifies that the `target()` function returns format-specific values. + +#context { + let format = target() + [Current format: *#format*] +} + +== Conditional Content + +#context if target() == "html" { + [HTML-specific content: This appears only in HTML output] +} else if target() == "pdf" or target() == "paged" { + [PDF-specific content: This appears only in PDF output] +} else if target() == "epub" { + [EPUB-specific content: This appears only in EPUB output] +} else { + [Unknown format detected] +} diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function/rheo.toml new file mode 100644 index 0000000..038e6ad --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function/rheo.toml @@ -0,0 +1,3 @@ +version = "0.2.0" + +formats = ["html", "pdf", "epub"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/lib/format_helper.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/lib/format_helper.typ new file mode 100644 index 0000000..1a0d06d --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/lib/format_helper.typ @@ -0,0 +1,19 @@ +// Module that uses target() function +// Tests whether target() polyfill propagates to imported files + +#let get_format() = { + target() +} + +#let format_specific_content() = context { + let fmt = target() + if fmt == "epub" { + [Module: EPUB] + } else if fmt == "html" { + [Module: HTML] + } else if fmt == "pdf" or fmt == "paged" { + [Module: PDF] + } else { + [Module: Unknown (#fmt)] + } +} diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/main.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/main.typ new file mode 100644 index 0000000..11ab260 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/main.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:formats html,pdf,epub +// @rheo:description Verifies target() works in imported modules + +#import "lib/format_helper.typ": get_format, format_specific_content + += Target Function in Module + +== Main File +#context [Main: *#target()*] + +== Imported Module +#context [Module returns: *#get_format()*] + +== Module Conditional +#format_specific_content() diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/rheo.toml new file mode 100644 index 0000000..88af797 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_module/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" +formats = ["html", "pdf", "epub"] + +[epub.spine] +title = "Target Function in Module" +vertebrae = ["main.typ"] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/main.typ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/main.typ new file mode 100644 index 0000000..1901929 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/main.typ @@ -0,0 +1,35 @@ +// @rheo:test +// @rheo:formats html,epub +// @rheo:description Tests target() polyfill vs packages using std.target() +// +// This test demonstrates rheo's target() polyfill: +// +// - User code using target() sees "epub" for EPUB output (via polyfill) +// - Universe packages that call std.target() see "html" (the underlying compile target) +// +// Why packages see "html": +// - EPUB compilation uses Typst's HTML export internally +// - Packages like bullseye explicitly call std.target() to get the "real" target +// - This is expected behavior - std.target() returns the underlying format +// +// For package authors: +// - Packages can adopt rheo's pattern to detect rheo output format +// - The pattern: `if "rheo-target" in sys.inputs { sys.inputs.rheo-target } else { target() }` +// - This provides graceful degradation when compiled outside rheo + +#import "@preview/bullseye:0.1.0": on-target + += Target Function in Package + +== Using bullseye package + +// Expected: "html" in both HTML and EPUB modes (bullseye calls std.target()) +#context on-target( + html: [Package sees: *html*], + paged: [Package sees: *paged*], +) + +== Using target() + +// Expected: "html" for HTML, "epub" for EPUB (uses polyfill) +Main file target: #context [*#target()*] diff --git a/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/rheo.toml b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/rheo.toml new file mode 100644 index 0000000..0e3402a --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashmultiple_links_inline_full_stoptyp/target_function_in_package/rheo.toml @@ -0,0 +1,6 @@ +version = "0.2.0" +formats = ["html", "epub"] + +[epub.spine] +title = "Target Function in Package" +vertebrae = ["main.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/code_blocks_with_links/code_examples.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/code_blocks_with_links/code_examples.typ new file mode 100644 index 0000000..1b7df43 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/code_blocks_with_links/code_examples.typ @@ -0,0 +1,27 @@ += Code Blocks with Links Test + +This document tests that link transformation correctly handles code blocks. + +== Real Links + +Real links should be transformed: #link("./other.typ")[see other page]. + +Multiple links: #link("./intro.typ")[intro] and #link("./conclusion.typ")[conclusion]. + +== Code Examples + +Inline code should preserve links: `#link("./file.typ")[example]`. + +Code block example: +``` +// This link should be preserved: +#link("./other.typ")[other page] +``` + +== Mixed Content + +Real link: #link("./chapter1.typ")[Chapter 1] + +Then code: `#link("./code.typ")[code link]` + +And another real link: #link("./chapter2.typ")[Chapter 2] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/code_blocks_with_links/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/code_blocks_with_links/rheo.toml new file mode 100644 index 0000000..788c7bb --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/code_blocks_with_links/rheo.toml @@ -0,0 +1,3 @@ + +# Test PDF transformation +formats = ["pdf"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/appendix/notes.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/appendix/notes.typ new file mode 100644 index 0000000..9457824 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/appendix/notes.typ @@ -0,0 +1,13 @@ += Appendix: Notes + +Additional notes and references. + +== Cross References + +Back to #link("../chapters/ch1.typ")[Chapter 1]. + +Return to #link("../intro.typ")[the introduction]. + +== Details + +Testing links from a different subdirectory. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/chapters/ch1.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/chapters/ch1.typ new file mode 100644 index 0000000..b62d95b --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/chapters/ch1.typ @@ -0,0 +1,11 @@ += Chapter 1 + +This is the first chapter. + +== References + +Go back to #link("../intro.typ")[the introduction]. + +Continue to #link("ch2.typ")[Chapter 2] (sibling). + +See #link("../appendix/notes.typ")[the appendix notes] for additional info. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/chapters/ch2.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/chapters/ch2.typ new file mode 100644 index 0000000..0bd8187 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/chapters/ch2.typ @@ -0,0 +1,13 @@ += Chapter 2 + +This is the second chapter. + +== Navigation + +Previous: #link("ch1.typ")[Chapter 1] + +Root: #link("../intro.typ")[Introduction] + +== Content + +Testing cross-directory navigation patterns. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/intro.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/intro.typ new file mode 100644 index 0000000..bdabdac --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/intro.typ @@ -0,0 +1,9 @@ += Introduction + +Welcome to the cross-directory test. + +== Overview + +This document links to #link("chapters/ch1.typ")[Chapter 1]. + +See also #link("chapters/ch2.typ")[Chapter 2] for more details. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/rheo.toml new file mode 100644 index 0000000..a415774 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/cross_directory_links/rheo.toml @@ -0,0 +1,7 @@ + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Cross Directory Test" +vertebrae = ["intro.typ", "chapters/ch1.typ", "chapters/ch2.typ", "appendix/notes.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/chapter.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/chapter.typ new file mode 100644 index 0000000..236b6df --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/chapter.typ @@ -0,0 +1,5 @@ +#set document(title: [Main Chapter]) + += Chapter 1 + +The main content. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/intro.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/intro.typ new file mode 100644 index 0000000..e917334 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/intro.typ @@ -0,0 +1,5 @@ +#set document(title: [Introduction]) + += Introduction + +Welcome to the book. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/rheo.toml new file mode 100644 index 0000000..bc56fb6 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_explicit_spine/rheo.toml @@ -0,0 +1,4 @@ + +[epub.spine] +title = "My EPUB Book" +vertebrae = ["intro.typ", "chapter.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/a.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/a.typ new file mode 100644 index 0000000..e01171a --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/a.typ @@ -0,0 +1,5 @@ +#set document(title: "Part A") + += Part A + +This is the first part of the document. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/b.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/b.typ new file mode 100644 index 0000000..7f0dea7 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/b.typ @@ -0,0 +1,5 @@ +#set document(title: "Part B") + += Part B + +This is the second part of the document. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/c.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/c.typ new file mode 100644 index 0000000..3941877 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/epub_inferred_spine/c.typ @@ -0,0 +1,5 @@ +#set document(title: "Part C") + += Part C + +This is the third part of the document. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/array_index_error.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/array_index_error.typ new file mode 100644 index 0000000..cb27669 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/array_index_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "array_index_error.typ", "│" +// @rheo:formats pdf +// Test file with array index error + += Array Index Error Test + +// Create a small array +#let items = ("first", "second", "third") + +// Try to access an index that doesn't exist +#items.at(10) + +Some content diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/function_arg_error.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/function_arg_error.typ new file mode 100644 index 0000000..396014b --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/function_arg_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "function_arg_error.typ", "│" +// @rheo:formats pdf +// Test file with function argument error + += Function Argument Error Test + +// Define a function that requires two arguments +#let add_numbers(x, y) = x + y + +// Call it with only one argument (missing required argument) +#add_numbers(5) + +Some content diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/import_error.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/import_error.typ new file mode 100644 index 0000000..ca1820c --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/import_error.typ @@ -0,0 +1,12 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "import_error.typ", "│" +// @rheo:formats pdf +// Test file with import error (missing file) + += Import Error Test + +// Try to include a file that doesn't exist +#include "nonexistent_file.typ" + +Some content diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/invalid_field.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/invalid_field.typ new file mode 100644 index 0000000..53a0410 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/invalid_field.typ @@ -0,0 +1,18 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "invalid_field.typ", "│" +// @rheo:formats pdf +// Test file with invalid field access + += Invalid Field Access Test + +// Create a dictionary and try to access non-existent field +#let person = ( + name: "Alice", + age: 30 +) + +// Try to access a field that doesn't exist +#person.nonexistent_field + +Some content diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/invalid_method.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/invalid_method.typ new file mode 100644 index 0000000..9b634d3 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/invalid_method.typ @@ -0,0 +1,13 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "invalid_method.typ", "│" +// @rheo:formats pdf +// Test file with invalid method call + += Invalid Method Test + +// Try to call a method that doesn't exist on strings +#let text = "hello" +#let result = text.nonexistent_method() + +Some content diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/multiple_errors.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/multiple_errors.typ new file mode 100644 index 0000000..63b4c70 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/multiple_errors.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "multiple_errors.typ", "│" +// @rheo:formats pdf +// Test file with multiple errors + += Multiple Errors Test + +// First error: undefined variable +#let x = undefined_var_one + +// Second error: type mismatch +#let y = 5 + "string" + +// Third error: undefined variable again +The value is: #undefined_var_two diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/rheo.toml new file mode 100644 index 0000000..385f755 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/rheo.toml @@ -0,0 +1,6 @@ + +# Test project for error formatting validation +formats = ["pdf"] + +[pdf] +# Don't merge - test single file errors diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/syntax_error.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/syntax_error.typ new file mode 100644 index 0000000..4dd311e --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/syntax_error.typ @@ -0,0 +1,15 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "syntax_error.typ", "│" +// @rheo:formats pdf +// Test file with syntax error (unclosed delimiter) + += Syntax Error Test + +#let items = [ + Item 1, + Item 2, + Item 3 +// Missing closing bracket ] + +Content follows diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/type_error.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/type_error.typ new file mode 100644 index 0000000..01aa6cd --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/type_error.typ @@ -0,0 +1,16 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "type_error.typ", "│" +// @rheo:formats pdf +// Test file with type error +// This should trigger a Typst compilation error + += Type Error Test + +#let x = 5 +#let y = "hello" + +// This will cause a type error: can't add number and string +#let result = x + y + +Content: #result diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/undefined_var.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/undefined_var.typ new file mode 100644 index 0000000..e73bbae --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/undefined_var.typ @@ -0,0 +1,10 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "undefined_var.typ", "undefined_variable", "│" +// @rheo:formats pdf +// Test file with undefined variable error + += Undefined Variable Test + +// This will cause an error: undefined_variable doesn't exist +The value is: #undefined_variable diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/unknown_function.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/unknown_function.typ new file mode 100644 index 0000000..0d3e287 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/error_formatting/unknown_function.typ @@ -0,0 +1,12 @@ +// @rheo:test +// @rheo:expect error +// @rheo:error-patterns "error", "unknown_function.typ", "│" +// @rheo:formats pdf +// Test file with unknown function call + += Unknown Function Test + +// Call a function that doesn't exist +#nonexistent_function("arg1", "arg2") + +Some content here diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/about.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/about.typ new file mode 100644 index 0000000..1e9f1c4 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/about.typ @@ -0,0 +1,5 @@ +#set document(title: [About]) + += About + +Information about the site. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/index.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/index.typ new file mode 100644 index 0000000..f041468 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/index.typ @@ -0,0 +1,7 @@ +#set document(title: [Home]) + += Welcome + +This is the home page. + +See also: @about diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/rheo.toml new file mode 100644 index 0000000..f163c7b --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/html_spine/rheo.toml @@ -0,0 +1,4 @@ + +[html.spine] +title = "My Website" +vertebrae = ["index.typ", "about.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/chapter-01.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/chapter-01.typ new file mode 100644 index 0000000..90d0f99 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/chapter-01.typ @@ -0,0 +1,5 @@ += Chapter 01 + +This filename contains numbers. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/file-name.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/file-name.typ new file mode 100644 index 0000000..3fd2139 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/file-name.typ @@ -0,0 +1,5 @@ += File with Hyphen + +This filename contains a hyphen. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/file_name.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/file_name.typ new file mode 100644 index 0000000..b786beb --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/file_name.typ @@ -0,0 +1,5 @@ += File with Underscore + +This filename contains an underscore. + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/main.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/main.typ new file mode 100644 index 0000000..b557e5f --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/main.typ @@ -0,0 +1,17 @@ += Path Edge Cases Test + +This tests unusual but valid filename patterns. + +== Links to Edge Case Files + +Hyphen: #link("file-name.typ")[file with hyphen] + +Underscore: #link("file_name.typ")[file with underscore] + +Dot in name: #link("version-1.0.typ")[file with dot] + +Number: #link("chapter-01.typ")[file with number] + +== Content + +All these edge cases should transform correctly. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/rheo.toml new file mode 100644 index 0000000..aa46594 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/rheo.toml @@ -0,0 +1,7 @@ + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Path Edge Cases Test" +vertebrae = ["main.typ", "file-name.typ", "file_name.typ", "version-1.0.typ", "chapter-01.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/version-1.0.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/version-1.0.typ new file mode 100644 index 0000000..7fc2556 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_path_edge_cases/version-1.0.typ @@ -0,0 +1,5 @@ += Version 1.0 + +This filename contains a dot in the name (not just the extension). + +Back to #link("main.typ")[main]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/doc1.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/doc1.typ new file mode 100644 index 0000000..69c2f08 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/doc1.typ @@ -0,0 +1,12 @@ +// @test-formats: pdf,html,epub +// @test-description: Verify AST-based .typ link transformation + += Document 1 + +This is the first document. + +You can navigate to #link("./doc2.typ")[See Doc 2] for more information. + +== Section in Doc 1 + +More content here. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/doc2.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/doc2.typ new file mode 100644 index 0000000..5925102 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/doc2.typ @@ -0,0 +1,9 @@ += Document 2 + +This is the second document. + +Go #link("./doc1.typ")[Back to Doc 1] to see the first document. + +== Another Section + +Additional content in document 2. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/rheo.toml new file mode 100644 index 0000000..3e4e50f --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/link_transformation/rheo.toml @@ -0,0 +1,9 @@ + +[pdf.spine] +merge = true +title = "Link Transformation Test" +vertebrae = ["doc1.typ", "doc2.typ"] + +[epub.spine] +title = "Link Transformation Test" +vertebrae = ["doc1.typ", "doc2.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/page1.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/page1.typ new file mode 100644 index 0000000..9164d4d --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/page1.typ @@ -0,0 +1,11 @@ += Page 1 + +This is the first page. + +See the #link("./page2.typ#intro")[introduction in Page 2] for details. + +Also check #link("./page2.typ#conclusion")[the conclusion]. + +== Section in Page 1 + +More content here. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/page2.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/page2.typ new file mode 100644 index 0000000..82d874d --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/page2.typ @@ -0,0 +1,19 @@ += Page 2 + +This is the second page. + +== Introduction + +This is the introduction section. + +It has some content that the first page links to. + +== Middle Section + +Some middle content. + +== Conclusion + +This is the conclusion section. + +Referenced from page 1. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/rheo.toml new file mode 100644 index 0000000..b9fb6d4 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/links_with_fragments/rheo.toml @@ -0,0 +1,11 @@ + +formats = ["html", "pdf", "epub"] + +[pdf.spine] +merge = true +title = "Links with Fragments Test" +vertebrae = ["page1.typ", "page2.typ"] + +[epub.spine] +title = "Links with Fragments Test" +vertebrae = ["page1.typ", "page2.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/multiple_links_inline.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/multiple_links_inline.typ new file mode 100644 index 0000000..cf1189a --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/multiple_links_inline.typ @@ -0,0 +1,21 @@ +// @rheo:test +// @rheo:formats html,pdf +// @rheo:description Multiple links per line test + += Multiple Links Test + +== Adjacent Links with Text + +See #link("file1.typ")[File 1] and #link("file2.typ")[File 2] for details. + +== Multiple References in List + +References: #link("a.typ")[A], #link("b.typ")[B], #link("c.typ")[C]. + +== Minimal Separation + +Adjacent links: #link("x.typ")[X]#link("y.typ")[Y] + +== Multiple Links in Sentence + +Check #link("intro.typ")[the introduction], then #link("chapter1.typ")[chapter 1], and finally #link("conclusion.typ")[the conclusion]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/chapter1.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/chapter1.typ new file mode 100644 index 0000000..7b27ae2 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/chapter1.typ @@ -0,0 +1,7 @@ +#set document(title: "Chapter 1") + += Chapter 1 + +This is the first chapter. + +See also: #link("./chapter2.typ")[Chapter 2] for more information. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/chapter2.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/chapter2.typ new file mode 100644 index 0000000..a41be40 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/chapter2.typ @@ -0,0 +1,7 @@ +#set document(title: "Chapter 2") + += Chapter 2 + +This is the second chapter. + +Refer back to #link("./chapter1.typ")[Chapter 1] if needed. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/rheo.toml new file mode 100644 index 0000000..c8a5354 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_individual/rheo.toml @@ -0,0 +1,2 @@ + +formats = ["pdf"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/chapter1.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/chapter1.typ new file mode 100644 index 0000000..0241079 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/chapter1.typ @@ -0,0 +1,5 @@ += Chapter 1 + +First chapter content. + +See also #link("./chapter2.typ")[Chapter 2]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/chapter2.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/chapter2.typ new file mode 100644 index 0000000..0cb7bf5 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/chapter2.typ @@ -0,0 +1,7 @@ += Chapter 2 + +Second chapter content. + +Refer back to #link("./chapter1.typ")[Chapter 1]. + +Jump to #link("./conclusion.typ")[Conclusion]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/conclusion.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/conclusion.typ new file mode 100644 index 0000000..28e2cf2 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/conclusion.typ @@ -0,0 +1,3 @@ += Conclusion + +Final thoughts. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/intro.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/intro.typ new file mode 100644 index 0000000..f21fd34 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/intro.typ @@ -0,0 +1,5 @@ += Introduction + +This is the introduction. + +Continue to #link("./chapter1.typ")[Chapter 1]. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/rheo.toml new file mode 100644 index 0000000..b017c4d --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_merge/rheo.toml @@ -0,0 +1,5 @@ + +[pdf.spine] +merge = true +vertebrae = ["intro.typ", "chapter*.typ", "conclusion.typ"] +title = "Test Merged Document" diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/file1.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/file1.typ new file mode 100644 index 0000000..6e6e5e5 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/file1.typ @@ -0,0 +1,5 @@ +#set document(title: [File 1]) + += Chapter 1 + +This is the first file. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/file2.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/file2.typ new file mode 100644 index 0000000..4cec706 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/file2.typ @@ -0,0 +1,5 @@ +#set document(title: [File 2]) + += Chapter 2 + +This is the second file. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/rheo.toml new file mode 100644 index 0000000..88e6578 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/pdf_spine_merge_false/rheo.toml @@ -0,0 +1,5 @@ + +[pdf.spine] +title = "Individual PDFs" +vertebrae = ["file*.typ"] +merge = false diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/rheo.toml b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/rheo.toml new file mode 100644 index 0000000..16bf946 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/rheo.toml @@ -0,0 +1,7 @@ + +formats = ["html", "pdf"] + +[pdf.spine] +merge = true +title = "Relative Path Test" +vertebrae = ["root.typ", "subdir/child.typ", "subdir/sibling.typ"] diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/root.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/root.typ new file mode 100644 index 0000000..4ad9a58 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/root.typ @@ -0,0 +1,13 @@ += Root Document + +This is the root of the test project. + +== Links to Subdirectory + +See #link("subdir/child.typ")[the child document] in the subdir. + +Also check out #link("subdir/sibling.typ")[the sibling]. + +== More Content + +This tests that subdirectory paths transform correctly. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/subdir/child.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/subdir/child.typ new file mode 100644 index 0000000..ed0437a --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/subdir/child.typ @@ -0,0 +1,15 @@ += Child Document + +This document is in a subdirectory. + +== Link to Parent Directory + +Go back to #link("../root.typ")[the root document]. + +== Link to Sibling (Explicit Same Dir) + +See #link("./sibling.typ")[the sibling] in the same directory. + +== Link to Sibling (Implicit Same Dir) + +Also see #link("sibling.typ")[the sibling again] with implicit path. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/subdir/sibling.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/subdir/sibling.typ new file mode 100644 index 0000000..b1ad7a1 --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/relative_path_links/subdir/sibling.typ @@ -0,0 +1,15 @@ += Sibling Document + +This is the sibling document in the subdirectory. + +== Link to Sibling + +Go to #link("child.typ")[the child document]. + +== Link to Parent Directory + +Return to #link("../root.typ")[root]. + +== Content + +Testing various relative path patterns. diff --git a/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/target_function.typ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/target_function.typ new file mode 100644 index 0000000..7486fea --- /dev/null +++ b/crates/tests/store/tests_slashcases_slashtarget_function_full_stoptyp/target_function.typ @@ -0,0 +1,24 @@ +// @rheo:test +// @rheo:formats html,pdf,epub +// @rheo:description Verifies target() function returns correct format string + += Target Function Test + +This test verifies that the `target()` function returns format-specific values. + +#context { + let format = target() + [Current format: *#format*] +} + +== Conditional Content + +#context if target() == "html" { + [HTML-specific content: This appears only in HTML output] +} else if target() == "pdf" or target() == "paged" { + [PDF-specific content: This appears only in PDF output] +} else if target() == "epub" { + [EPUB-specific content: This appears only in EPUB output] +} else { + [Unknown format detected] +} diff --git a/tests/harness.rs b/crates/tests/tests/harness.rs similarity index 64% rename from tests/harness.rs rename to crates/tests/tests/harness.rs index 36271af..284e574 100644 --- a/tests/harness.rs +++ b/crates/tests/tests/harness.rs @@ -1,45 +1,43 @@ -mod helpers; - -use helpers::{ +use ntest::test_case; +use rheo_core::{RheoConfig, project::ProjectConfig}; +use rheo_tests::helpers::{ comparison::{verify_epub_output, verify_html_output, verify_pdf_output}, fixtures::TestCase, reference::{update_epub_references, update_html_references, update_pdf_references}, test_store::copy_project_to_test_store, }; -use ntest::test_case; -use rheo::{OutputFormat, RheoConfig, project::ProjectConfig}; use std::env; use std::path::PathBuf; -#[test_case("examples/blog_site")] -#[test_case("examples/blog_post")] -#[test_case("examples/cover-letter.typ")] -#[test_case("examples/blog_site/content/index.typ")] -#[test_case("examples/blog_site/content/severance-ep-1.typ")] -#[test_case("examples/blog_post/portable_epubs.typ")] -#[test_case("tests/cases/code_blocks_with_links")] -#[test_case("tests/cases/cross_directory_links")] -#[test_case("tests/cases/epub_inferred_spine")] -#[test_case("tests/cases/link_path_edge_cases")] -#[test_case("tests/cases/link_transformation")] -#[test_case("tests/cases/links_with_fragments")] -#[test_case("tests/cases/multiple_links_inline.typ")] -#[test_case("tests/cases/pdf_individual")] -#[test_case("tests/cases/pdf_merge_false")] -#[test_case("tests/cases/relative_path_links")] -#[test_case("tests/cases/target_function")] -#[test_case("tests/cases/target_function_in_module")] -#[test_case("tests/cases/target_function_in_package")] -#[test_case("tests/cases/error_formatting/type_error.typ")] -#[test_case("tests/cases/error_formatting/undefined_var.typ")] -#[test_case("tests/cases/error_formatting/syntax_error.typ")] -#[test_case("tests/cases/error_formatting/function_arg_error.typ")] -#[test_case("tests/cases/error_formatting/import_error.typ")] -#[test_case("tests/cases/error_formatting/unknown_function.typ")] -#[test_case("tests/cases/error_formatting/invalid_method.typ")] -#[test_case("tests/cases/error_formatting/invalid_field.typ")] -#[test_case("tests/cases/error_formatting/multiple_errors.typ")] -#[test_case("tests/cases/error_formatting/array_index_error.typ")] +#[test_case("../../examples/blog_site")] +#[test_case("../../examples/blog_post")] +#[test_case("../../examples/cover-letter.typ")] +#[test_case("../../examples/blog_site/content/index.typ")] +#[test_case("../../examples/blog_site/content/severance-ep-1.typ")] +#[test_case("../../examples/blog_post/portable_epubs.typ")] +#[test_case("cases/code_blocks_with_links")] +#[test_case("cases/cross_directory_links")] +#[test_case("cases/epub_inferred_spine")] +#[test_case("cases/link_path_edge_cases")] +#[test_case("cases/link_transformation")] +#[test_case("cases/links_with_fragments")] +#[test_case("cases/multiple_links_inline.typ")] +#[test_case("cases/pdf_individual")] +#[test_case("cases/pdf_merge_false")] +#[test_case("cases/relative_path_links")] +#[test_case("cases/target_function")] +#[test_case("cases/target_function_in_module")] +#[test_case("cases/target_function_in_package")] +#[test_case("cases/error_formatting/type_error.typ")] +#[test_case("cases/error_formatting/undefined_var.typ")] +#[test_case("cases/error_formatting/syntax_error.typ")] +#[test_case("cases/error_formatting/function_arg_error.typ")] +#[test_case("cases/error_formatting/import_error.typ")] +#[test_case("cases/error_formatting/unknown_function.typ")] +#[test_case("cases/error_formatting/invalid_method.typ")] +#[test_case("cases/error_formatting/invalid_field.typ")] +#[test_case("cases/error_formatting/multiple_errors.typ")] +#[test_case("cases/error_formatting/array_index_error.typ")] fn run_test_case(name: &str) { let test_case = TestCase::new(name); let update_mode = env::var("UPDATE_REFERENCES").is_ok(); @@ -47,7 +45,7 @@ fn run_test_case(name: &str) { let original_project_path = test_case.project_path(); // Create isolated test store - let test_store = PathBuf::from("tests/store").join(test_name); + let test_store = PathBuf::from("store").join(test_name); // Clean previous test artifacts if test_store.exists() { @@ -101,21 +99,28 @@ fn run_test_case(name: &str) { // Compute which formats to actually run // For single-file tests: use declared formats (config check optional, markers are authoritative) // For directory tests: require config support (preserve existing behavior) - let run_html = declared_formats.contains(&OutputFormat::Html) + let run_html = declared_formats.iter().any(|f| f == "html") && (run_all || env_html) - && (test_case.is_single_file() || config.as_ref().is_ok_and(|cfg| cfg.has_html())); - let run_pdf = declared_formats.contains(&OutputFormat::Pdf) + && (test_case.is_single_file() || config.as_ref().is_ok_and(|cfg| cfg.has_format("html"))); + let run_pdf = declared_formats.iter().any(|f| f == "pdf") && (run_all || env_pdf) - && (test_case.is_single_file() || config.as_ref().is_ok_and(|cfg| cfg.has_pdf())); - let run_epub = declared_formats.contains(&OutputFormat::Epub) + && (test_case.is_single_file() || config.as_ref().is_ok_and(|cfg| cfg.has_format("pdf"))); + let run_epub = declared_formats.iter().any(|f| f == "epub") && (run_all || env_epub) - && (test_case.is_single_file() || config.as_ref().is_ok_and(|cfg| cfg.has_epub())); + && (test_case.is_single_file() || config.as_ref().is_ok_and(|cfg| cfg.has_format("epub"))); // Get build directory in test store let build_dir = test_store.join("build"); // Build compile command with format flags - let mut compile_args = vec!["run", "--", "compile", project_path.to_str().unwrap()]; + let mut compile_args = vec![ + "run", + "-p", + "rheo-cli", + "--", + "compile", + project_path.to_str().unwrap(), + ]; // Use isolated build directory compile_args.push("--build-dir"); @@ -238,15 +243,15 @@ fn run_test_case(name: &str) { /// Test PDF merge functionality specifically #[test] fn test_pdf_merge() { - use helpers::comparison::extract_pdf_metadata; use lopdf::Document; + use rheo_tests::helpers::comparison::extract_pdf_metadata; let test_name = "pdf_merge"; - let test_case = TestCase::new(&format!("tests/cases/{}", test_name)); + let test_case = TestCase::new(&format!("cases/{}", test_name)); let original_project_path = test_case.project_path(); // Create isolated test store - let test_store = PathBuf::from("tests/store").join(test_name); + let test_store = PathBuf::from("store").join(test_name); if test_store.exists() { std::fs::remove_dir_all(&test_store).expect("Failed to clean test store"); } @@ -261,6 +266,8 @@ fn test_pdf_merge() { let output = std::process::Command::new("cargo") .args([ "run", + "-p", + "rheo-cli", "--", "compile", project_path.to_str().unwrap(), @@ -440,12 +447,19 @@ Content from dir2. /// Test HTML post-processing: CSS link injection #[test] fn test_html_css_link_injection() { - let test_case = TestCase::new("examples/blog_site"); + let test_case = TestCase::new("../../examples/blog_site"); let project_path = test_case.project_path(); // Clean and compile let clean_output = std::process::Command::new("cargo") - .args(["run", "--", "clean", project_path.to_str().unwrap()]) + .args([ + "run", + "-p", + "rheo-cli", + "--", + "clean", + project_path.to_str().unwrap(), + ]) .output() .expect("Failed to run rheo clean"); @@ -459,6 +473,8 @@ fn test_html_css_link_injection() { let output = std::process::Command::new("cargo") .args([ "run", + "-p", + "rheo-cli", "--", "compile", project_path.to_str().unwrap(), @@ -479,20 +495,24 @@ fn test_html_css_link_injection() { let html_path = project_path.join("build/html/index.html"); let html = std::fs::read_to_string(&html_path).expect("Failed to read HTML file"); - // Test 1: CSS stylesheet link is present in head + // Test 1: CSS is inlined as a