Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f013b06
Updates git submodules to latest
breezykermo Dec 30, 2025
99d83c1
Updates git submodules to latest
breezykermo Jan 13, 2026
10696c6
Returns "epub" in target() during EPUB compilation
breezykermo Jan 13, 2026
5e172f8
Updates git submodules to latest
breezykermo Jan 13, 2026
1237521
Extracts XHTML content from EPUB archives for testing
breezykermo Jan 16, 2026
74776f7
Saves EPUB XHTML content to reference files during test updates
breezykermo Jan 16, 2026
b720c3f
Verifies EPUB XHTML content against reference files during tests
breezykermo Jan 16, 2026
2d8c5f2
Tests target() function behavior in imported modules
breezykermo Jan 16, 2026
556a0f5
Adds XHTML reference files for all EPUB tests
breezykermo Jan 16, 2026
6a60968
Updates EPUB module reference output to desired behavior
breezykermo Jan 16, 2026
54da44d
Injects target() override into all Typst files during EPUB compilation
breezykermo Jan 16, 2026
7c97294
Tests target() override in Typst Universe packages
breezykermo Jan 16, 2026
bcd4149
Uses sys.inputs for EPUB target detection instead of target() override
breezykermo Jan 16, 2026
9e22dfd
Documents sys.inputs.rheo-target pattern for EPUB detection
breezykermo Jan 16, 2026
e1c929f
Injects target() polyfill using sys.inputs for EPUB compilation
breezykermo Jan 16, 2026
28f20f1
Documents sys.inputs.rheo-target for library developers
breezykermo Jan 16, 2026
93cc438
Updates git submodules to latest
breezykermo Jan 16, 2026
3cfe124
Fixes lints
breezykermo Jan 16, 2026
1362646
Updates git submodules to latest
breezykermo Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,60 @@ cargo run -- compile my_project/ --epub

**Note:** Existing projects with explicit `rheo.toml` configurations are not affected—explicit configs always take precedence over inferred defaults.

### Format Detection in Typst Code

Rheo polyfills the `target()` function for EPUB compilation, so you can use standard Typst patterns:

**Basic usage (recommended):**

```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:

```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
}
}
```

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:**
Expand Down
2 changes: 1 addition & 1 deletion examples/blog_post/portable_epubs.typ
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// @rheo:description Long-form article with custom HTML elements and code examples

#let html-element(body, name: "div", attrs: (:)) = context {
if target() == "html" {
if target() == "html" or target() == "epub" {
html.elem(name, attrs: attrs, body)
} else {
block(body)
Expand Down
2 changes: 1 addition & 1 deletion examples/blog_site/content/index.typ
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

#let template(doc) = {
doc
context if target() == "html" {
context if target() == "html" or target() == "epub" {
div[
#br()
#hr()
Expand Down
2 changes: 1 addition & 1 deletion examples/fcl_site
Submodule fcl_site updated 2 files
+5 −13 build.sh
+5 −4 pages/index.typ
8 changes: 5 additions & 3 deletions src/rs/formats/epub/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use xhtml::HtmlInfo;
use crate::compile::RheoCompileOptions;
use crate::config::{EpubConfig, EpubOptions};
use crate::reticulate::spine::RheoSpine;
use crate::{Result, RheoError};
use crate::{OutputFormat, Result, RheoError};
use anyhow::Result as AnyhowResult;
use chrono::{DateTime, Utc};
use iref::{IriRef, IriRefBuf, iri::Fragment};
Expand Down Expand Up @@ -328,7 +328,8 @@ fn text_to_id(s: &str) -> EcoString {
impl EpubItem {
pub fn create(path: PathBuf, root: &Path) -> AnyhowResult<Self> {
info!(file = %path.display(), "compiling spine file");
let document = crate::formats::html::compile_html_to_document(&path, root)?;
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())?;
Expand Down Expand Up @@ -364,7 +365,8 @@ impl EpubItem {
let temp_path = temp_file.path();

// Compile to HTML document
let document = crate::formats::html::compile_html_to_document(temp_path, root)?;
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();
Expand Down
12 changes: 8 additions & 4 deletions src/rs/formats/html/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ use std::path::Path;
use tracing::{debug, info};
use typst_html::HtmlDocument;

pub fn compile_html_to_document(input: &Path, root: &Path) -> Result<HtmlDocument> {
// Create the compilation world with HTML format for link transformations
let world = RheoWorld::new(root, input, Some(OutputFormat::Html))?;
pub fn compile_html_to_document(
input: &Path,
root: &Path,
output_format: OutputFormat,
) -> Result<HtmlDocument> {
// Create the compilation world with specified format for link transformations
let world = RheoWorld::new(root, input, Some(output_format))?;

// Compile the document to HtmlDocument
info!(input = %input.display(), "compiling to HTML");
Expand Down Expand Up @@ -47,7 +51,7 @@ fn compile_html_impl_fresh(
html_options: &HtmlOptions,
) -> Result<()> {
// Compile to HTML document (transformations happen in RheoWorld)
let doc = compile_html_to_document(input, root)?;
let doc = compile_html_to_document(input, root, OutputFormat::Html)?;
let html_string = compile_document_to_string(&doc)?;

// Inject CSS and font links into <head>
Expand Down
51 changes: 45 additions & 6 deletions src/rs/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use chrono::{Datelike, Local};
use codespan_reporting::files::{Error as CodespanError, Files};
use parking_lot::Mutex;
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime};
use typst::foundations::{Bytes, Datetime, Dict, IntoValue};
use typst::syntax::{FileId, Lines, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
Expand All @@ -17,6 +17,25 @@ use typst_kit::fonts::{FontSlot, Fonts};
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<OutputFormat>) -> 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());
}
dict
}

/// A simple World implementation for rheo compilation.
pub struct RheoWorld {
/// The root directory for resolving imports (document directory).
Expand Down Expand Up @@ -80,9 +99,13 @@ impl RheoWorld {
})?;
let main = FileId::new(None, main_vpath);

// Build library with HTML feature enabled
// Build library with HTML feature enabled and sys.inputs for format detection
let features: Features = [Feature::Html].into_iter().collect();
let library = Library::builder().with_features(features).build();
let inputs = build_inputs(output_format);
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
Expand Down Expand Up @@ -287,12 +310,28 @@ impl World for RheoWorld {
let path = self.path_for_id(id)?;
let mut text = fs::read_to_string(&path).map_err(|e| FileError::from_io(e, &path))?;

// For the main file, inject the rheo.typ template automatically
// 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)) {
"// 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
if id == self.main {
// Embed rheo.typ content directly (it's small and avoids path issues)
let rheo_content = include_str!("../typ/rheo.typ");
let template_inject = format!("{}\n#show: rheo_template\n\n", rheo_content);
let template_inject = format!(
"{}{}\n#show: rheo_template\n\n",
target_polyfill, rheo_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
Expand Down
17 changes: 17 additions & 0 deletions src/typ/rheo.typ
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
// Get the rheo output format, with fallback to Typst's target()
// Returns: "epub", "html", "pdf" when compiled with rheo
// "html" or "paged" when compiled with vanilla Typst
#let rheo-target() = {
if "rheo-target" in sys.inputs {
sys.inputs.rheo-target
} else {
target()
}
}

// Check if we're compiling for a specific rheo format
// Works in vanilla Typst (returns false when rheo-target not set)
#let is-rheo-epub() = "rheo-target" in sys.inputs and sys.inputs.rheo-target == "epub"
#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()
Expand Down
24 changes: 24 additions & 0 deletions tests/cases/target_function/main.typ
Original file line number Diff line number Diff line change
@@ -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]
}
3 changes: 3 additions & 0 deletions tests/cases/target_function/rheo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
version = "0.1.0"

formats = ["html", "pdf", "epub"]
19 changes: 19 additions & 0 deletions tests/cases/target_function_in_module/lib/format_helper.typ
Original file line number Diff line number Diff line change
@@ -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)]
}
}
16 changes: 16 additions & 0 deletions tests/cases/target_function_in_module/main.typ
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 6 additions & 0 deletions tests/cases/target_function_in_module/rheo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version = "0.1.0"
formats = ["html", "pdf", "epub"]

[epub.spine]
title = "Target Function in Module"
vertebrae = ["main.typ"]
35 changes: 35 additions & 0 deletions tests/cases/target_function_in_package/main.typ
Original file line number Diff line number Diff line change
@@ -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()*]
6 changes: 6 additions & 0 deletions tests/cases/target_function_in_package/rheo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version = "0.1.0"
formats = ["html", "epub"]

[epub.spine]
title = "Target Function in Package"
vertebrae = ["main.typ"]
3 changes: 3 additions & 0 deletions tests/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ use std::path::PathBuf;
#[test_case("tests/cases/multiple_links_inline.typ")]
#[test_case("tests/cases/pdf_individual")]
#[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")]
Expand Down
Loading