Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rheo"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
authors = ["Lachlan Kermode <lachie@ohrg.org>"]
description = "A typesetting and static site engine based on Typst"
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ rheo compile examples/blog_site --config /path/to/custom.toml
rheo compile examples/blog_site --build-dir /tmp/build
```

See [the documentation](https://rheo.ohrg) for more information regarding which flags are available.
See [the documentation](https://rheo.ohrg.org) for more information regarding which flags are available.

## Installation
### Using cargo (Recommended)
Expand All @@ -34,7 +34,7 @@ Install from [rustup.rs](https://rustup.rs/).

```bash
# Install from crates.io
cargo install rheo
cargo install --locked rheo

# Or build the project from source
git clone https://github.com/freecomputinglab/rheo
Expand Down Expand Up @@ -77,7 +77,7 @@ See the <a href="./about.html">about page</a> for more information. Visit <a hre

If a linked file doesn't exist, rheo will report a detailed error during compilation.

See [the documentation](https://rheo.ohrg) for more information.
See [the documentation](https://rheo.ohrg.org) for more information.
### Multi-Format Compilation
Rheo compiles Typst documents to three output formats simultaneously:

Expand Down Expand Up @@ -146,7 +146,7 @@ rheo compile my_book/ --epub
```
### TOML Configuration
Projects can include a `rheo.toml` configuration file in the project root to customize compilation behavior rather than specifying flags.
See [the documentation](https://rheo.ohrg) for more information.
See [the documentation](https://rheo.ohrg.org) for more information.

### CSS Styling
By default, rheo uses a simple, elegant and modern stylesheet to style your HTML.
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
58 changes: 42 additions & 16 deletions src/rs/formats/epub/xhtml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ use markup5ever_rcdom::{Handle, NodeData, RcDom};
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.
fn is_void_element(tag_name: &str) -> bool {
matches!(
tag_name,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}

/// Metadata about features used by the HTML generated by Typst.
pub struct HtmlInfo {
/// True if the document uses Javascript in any way.
Expand Down Expand Up @@ -102,25 +124,29 @@ pub fn html_to_portable_xhtml(html_string: &str, heading_ids: &[EcoString]) -> (
write!(self.buf, " id=\"{id}\"").unwrap();
}

write!(self.buf, ">").unwrap();
// Void elements use self-closing syntax in XHTML.
if is_void_element(&name.local) {
write!(self.buf, "/>").unwrap();
} else {
write!(self.buf, ">").unwrap();

// Wrap the children of the body in an <article>.
//
// TODO: should this be done within the Typst generator?
if &name.local == "body" {
self.buf.push_str("<article>");
}
// Wrap the children of the body in an <article>.
//
// TODO: should this be done within the Typst generator?
if &name.local == "body" {
self.buf.push_str("<article>");
}

for child in handle.children.borrow().iter() {
self.walk(child);
}
for child in handle.children.borrow().iter() {
self.walk(child);
}

if &name.local == "body" {
self.buf.push_str("</article>");
}
if &name.local == "body" {
self.buf.push_str("</article>");
}

// Unconditionally close all tags, to handle case like unclosed `<p>` or `<meta>`.
write!(self.buf, "</{}>", name.local).unwrap();
write!(self.buf, "</{}>", name.local).unwrap();
}
}

_ => {}
Expand Down Expand Up @@ -158,7 +184,7 @@ fn test_html_to_xhtml() {
let expected = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"><head>
<meta name="foo" content="bar"></meta>
<meta name="foo" content="bar"/>
</head>
<body><article>
<h2 id="test">Test</h2>
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
8 changes: 4 additions & 4 deletions src/rs/reticulate/transformer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ impl LinkTransformer {
let mut transformations = Vec::new();

// For Pdf (merged mode), build a map of filename stems to labels
let label_map = if self.output_format == OutputFormat::Pdf && self.spine.is_some() {
build_label_map(self.spine.as_ref().unwrap())
} else {
HashMap::new()

let label_map = match (&self.output_format, &self.spine) {
(OutputFormat::Pdf, Some(spine)) => build_label_map(spine),
_ => HashMap::new(),
};

for link in links {
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
Loading