summaryrefslogtreecommitdiff
path: root/crates/typst-cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-cli/src')
-rw-r--r--crates/typst-cli/src/args.rs15
-rw-r--r--crates/typst-cli/src/compile.rs130
-rw-r--r--crates/typst-cli/src/main.rs1
-rw-r--r--crates/typst-cli/src/server.rs217
-rw-r--r--crates/typst-cli/src/watch.rs13
-rw-r--r--crates/typst-cli/src/world.rs10
6 files changed, 325 insertions, 61 deletions
diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs
index 33fcb9fd..ead93236 100644
--- a/crates/typst-cli/src/args.rs
+++ b/crates/typst-cli/src/args.rs
@@ -97,6 +97,21 @@ pub struct WatchCommand {
/// Arguments for compilation.
#[clap(flatten)]
pub args: CompileArgs,
+
+ /// Disables the built-in HTTP server for HTML export.
+ #[clap(long)]
+ pub no_serve: bool,
+
+ /// Disables the injected live reload script for HTML export. The HTML that
+ /// is written to disk isn't affected either way.
+ #[clap(long)]
+ pub no_reload: bool,
+
+ /// The port where HTML is served.
+ ///
+ /// Defaults to the first free port in the range 3000-3005.
+ #[clap(long)]
+ pub port: Option<u16>,
}
/// Initializes a new project from a template.
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs
index 7d650fc8..01a6de1b 100644
--- a/crates/typst-cli/src/compile.rs
+++ b/crates/typst-cli/src/compile.rs
@@ -1,3 +1,4 @@
+use std::ffi::OsString;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
@@ -20,8 +21,9 @@ use typst_pdf::{PdfOptions, PdfStandards};
use crate::args::{
CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat,
- PdfStandard,
+ PdfStandard, WatchCommand,
};
+use crate::server::HtmlServer;
use crate::timings::Timer;
use crate::watch::Status;
@@ -33,15 +35,17 @@ type CodespanError = codespan_reporting::files::Error;
/// Execute a compilation command.
pub fn compile(timer: &mut Timer, command: &CompileCommand) -> StrResult<()> {
- let mut config = CompileConfig::new(&command.args)?;
+ let mut config = CompileConfig::new(command)?;
let mut world =
SystemWorld::new(&command.args.input, &command.args.world, &command.args.process)
.map_err(|err| eco_format!("{err}"))?;
- timer.record(&mut world, |world| compile_once(world, &mut config, false))?
+ timer.record(&mut world, |world| compile_once(world, &mut config))?
}
/// A preprocessed `CompileCommand`.
pub struct CompileConfig {
+ /// Whether we are watching.
+ pub watching: bool,
/// Path to input Typst file or stdin.
pub input: Input,
/// Path to output file (PDF, PNG, SVG, or HTML).
@@ -64,11 +68,27 @@ pub struct CompileConfig {
pub make_deps: Option<PathBuf>,
/// The PPI (pixels per inch) to use for PNG export.
pub ppi: f32,
+ /// The export cache for images, used for caching output files in `typst
+ /// watch` sessions with images.
+ pub export_cache: ExportCache,
+ /// Server for `typst watch` to HTML.
+ pub server: Option<HtmlServer>,
}
impl CompileConfig {
/// Preprocess a `CompileCommand`, producing a compilation config.
- pub fn new(args: &CompileArgs) -> StrResult<Self> {
+ pub fn new(command: &CompileCommand) -> StrResult<Self> {
+ Self::new_impl(&command.args, None)
+ }
+
+ /// Preprocess a `WatchCommand`, producing a compilation config.
+ pub fn watching(command: &WatchCommand) -> StrResult<Self> {
+ Self::new_impl(&command.args, Some(command))
+ }
+
+ /// The shared implementation of [`CompileConfig::new`] and
+ /// [`CompileConfig::watching`].
+ fn new_impl(args: &CompileArgs, watch: Option<&WatchCommand>) -> StrResult<Self> {
let input = args.input.clone();
let output_format = if let Some(specified) = args.format {
@@ -119,7 +139,17 @@ impl CompileConfig {
PdfStandards::new(&list)?
};
+ let mut server = None;
+ let mut watching = false;
+ if let Some(command) = watch {
+ watching = true;
+ if output_format == OutputFormat::Html && !command.no_serve {
+ server = Some(HtmlServer::new(&input, command.port, !command.no_reload)?);
+ }
+ }
+
Ok(Self {
+ watching,
input,
output,
output_format,
@@ -130,6 +160,8 @@ impl CompileConfig {
ppi: args.ppi,
diagnostic_format: args.process.diagnostic_format,
open: args.open.clone(),
+ export_cache: ExportCache::new(),
+ server,
})
}
}
@@ -141,21 +173,20 @@ impl CompileConfig {
pub fn compile_once(
world: &mut SystemWorld,
config: &mut CompileConfig,
- watching: bool,
) -> StrResult<()> {
let start = std::time::Instant::now();
- if watching {
+ if config.watching {
Status::Compiling.print(config).unwrap();
}
- let Warned { output, warnings } = compile_and_export(world, config, watching);
+ let Warned { output, warnings } = compile_and_export(world, config);
match output {
// Export the PDF / PNG.
Ok(()) => {
let duration = start.elapsed();
- if watching {
+ if config.watching {
if warnings.is_empty() {
Status::Success(duration).print(config).unwrap();
} else {
@@ -167,19 +198,14 @@ pub fn compile_once(
.map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
write_make_deps(world, config)?;
-
- if let Some(open) = config.open.take() {
- if let Output::Path(file) = &config.output {
- open_file(open.as_deref(), file)?;
- }
- }
+ open_output(config)?;
}
// Print diagnostics.
Err(errors) => {
set_failed();
- if watching {
+ if config.watching {
Status::Error.print(config).unwrap();
}
@@ -191,48 +217,48 @@ pub fn compile_once(
Ok(())
}
+/// Compile and then export the document.
fn compile_and_export(
world: &mut SystemWorld,
config: &mut CompileConfig,
- watching: bool,
) -> Warned<SourceResult<()>> {
match config.output_format {
OutputFormat::Html => {
let Warned { output, warnings } = typst::compile::<HtmlDocument>(world);
- let result = output.and_then(|document| {
- config
- .output
- .write(typst_html::html(&document)?.as_bytes())
- .map_err(|err| eco_format!("failed to write HTML file ({err})"))
- .at(Span::detached())
- });
+ let result = output.and_then(|document| export_html(&document, config));
Warned { output: result, warnings }
}
_ => {
let Warned { output, warnings } = typst::compile::<PagedDocument>(world);
- let result = output
- .and_then(|document| export_paged(world, &document, config, watching));
+ let result = output.and_then(|document| export_paged(&document, config));
Warned { output: result, warnings }
}
}
}
-/// Export into the target format.
-fn export_paged(
- world: &mut SystemWorld,
- document: &PagedDocument,
- config: &CompileConfig,
- watching: bool,
-) -> SourceResult<()> {
+/// Export to HTML.
+fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> {
+ let html = typst_html::html(document)?;
+ let result = config.output.write(html.as_bytes());
+
+ if let Some(server) = &config.server {
+ server.update(html);
+ }
+
+ result
+ .map_err(|err| eco_format!("failed to write HTML file ({err})"))
+ .at(Span::detached())
+}
+
+/// Export to a paged target format.
+fn export_paged(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> {
match config.output_format {
OutputFormat::Pdf => export_pdf(document, config),
OutputFormat::Png => {
- export_image(world, document, config, watching, ImageExportFormat::Png)
- .at(Span::detached())
+ export_image(document, config, ImageExportFormat::Png).at(Span::detached())
}
OutputFormat::Svg => {
- export_image(world, document, config, watching, ImageExportFormat::Svg)
- .at(Span::detached())
+ export_image(document, config, ImageExportFormat::Svg).at(Span::detached())
}
OutputFormat::Html => unreachable!(),
}
@@ -278,10 +304,8 @@ enum ImageExportFormat {
/// Export to one or multiple images.
fn export_image(
- world: &mut SystemWorld,
document: &PagedDocument,
config: &CompileConfig,
- watching: bool,
fmt: ImageExportFormat,
) -> StrResult<()> {
// Determine whether we have indexable templates in output
@@ -313,8 +337,6 @@ fn export_image(
bail!("cannot export multiple images {err}");
}
- let cache = world.export_cache();
-
// The results are collected in a `Vec<()>` which does not allocate.
exported_pages
.par_iter()
@@ -337,7 +359,10 @@ fn export_image(
// If we are not watching, don't use the cache.
// If the frame is in the cache, skip it.
// If the file does not exist, always create it.
- if watching && cache.is_cached(*i, &page.frame) && path.exists() {
+ if config.watching
+ && config.export_cache.is_cached(*i, &page.frame)
+ && path.exists()
+ {
return Ok(());
}
@@ -531,17 +556,26 @@ fn write_make_deps(world: &mut SystemWorld, config: &CompileConfig) -> StrResult
})
}
-/// Opens the given file using:
+/// Opens the output if desired, with:
/// - The default file viewer if `open` is `None`.
/// - The given viewer provided by `open` if it is `Some`.
///
/// If the file could not be opened, an error is returned.
-fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> {
- // Some resource openers require the path to be canonicalized.
- let path = path
- .canonicalize()
- .map_err(|err| eco_format!("failed to canonicalize path ({err})"))?;
- if let Some(app) = open {
+fn open_output(config: &mut CompileConfig) -> StrResult<()> {
+ let Some(open) = config.open.take() else { return Ok(()) };
+
+ let path = if let Some(server) = &config.server {
+ OsString::from(format!("http://{}", server.addr()))
+ } else if let Output::Path(path) = &config.output {
+ // Some resource openers require the path to be canonicalized.
+ path.canonicalize()
+ .map_err(|err| eco_format!("failed to canonicalize path ({err})"))?
+ .into_os_string()
+ } else {
+ return Ok(());
+ };
+
+ if let Some(app) = &open {
open::with_detached(&path, app)
.map_err(|err| eco_format!("failed to open file with {} ({})", app, err))
} else {
diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs
index 8cef1415..610f89c0 100644
--- a/crates/typst-cli/src/main.rs
+++ b/crates/typst-cli/src/main.rs
@@ -6,6 +6,7 @@ mod greet;
mod init;
mod package;
mod query;
+mod server;
mod terminal;
mod timings;
#[cfg(feature = "self-update")]
diff --git a/crates/typst-cli/src/server.rs b/crates/typst-cli/src/server.rs
new file mode 100644
index 00000000..b3ce83f8
--- /dev/null
+++ b/crates/typst-cli/src/server.rs
@@ -0,0 +1,217 @@
+use std::io::{self, Write};
+use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
+use std::sync::Arc;
+
+use ecow::eco_format;
+use parking_lot::{Condvar, Mutex, MutexGuard};
+use tiny_http::{Header, Request, Response, StatusCode};
+use typst::diag::{bail, StrResult};
+
+use crate::args::Input;
+
+/// Serves HTML with live reload.
+pub struct HtmlServer {
+ addr: SocketAddr,
+ bucket: Arc<Bucket<String>>,
+}
+
+impl HtmlServer {
+ /// Create a new HTTP server that serves live HTML.
+ pub fn new(input: &Input, port: Option<u16>, reload: bool) -> StrResult<Self> {
+ let (addr, server) = start_server(port)?;
+
+ let placeholder = PLACEHOLDER_HTML.replace("{INPUT}", &input.to_string());
+ let bucket = Arc::new(Bucket::new(placeholder));
+ let bucket2 = bucket.clone();
+
+ std::thread::spawn(move || {
+ for req in server.incoming_requests() {
+ let _ = handle(req, reload, &bucket2);
+ }
+ });
+
+ Ok(Self { addr, bucket })
+ }
+
+ /// The address that we serve the HTML on.
+ pub fn addr(&self) -> SocketAddr {
+ self.addr
+ }
+
+ /// Updates the HTML, triggering a reload all connected browsers.
+ pub fn update(&self, html: String) {
+ self.bucket.put(html);
+ }
+}
+
+/// Starts a local HTTP server.
+///
+/// Uses the specified port or tries to find a free port in the range
+/// `3000..=3005`.
+fn start_server(port: Option<u16>) -> StrResult<(SocketAddr, tiny_http::Server)> {
+ const BASE_PORT: u16 = 3000;
+
+ let mut addr;
+ let mut retries = 0;
+
+ let listener = loop {
+ addr = SocketAddr::new(
+ IpAddr::V4(Ipv4Addr::LOCALHOST),
+ port.unwrap_or(BASE_PORT + retries),
+ );
+
+ match TcpListener::bind(addr) {
+ Ok(listener) => break listener,
+ Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+ if let Some(port) = port {
+ bail!("port {port} is already in use")
+ } else if retries < 5 {
+ // If the port is in use, try the next one.
+ retries += 1;
+ } else {
+ bail!("could not find free port for HTTP server");
+ }
+ }
+ Err(err) => bail!("failed to start TCP server: {err}"),
+ }
+ };
+
+ let server = tiny_http::Server::from_listener(listener, None)
+ .map_err(|err| eco_format!("failed to start HTTP server: {err}"))?;
+
+ Ok((addr, server))
+}
+
+/// Handles a request.
+fn handle(req: Request, reload: bool, bucket: &Arc<Bucket<String>>) -> io::Result<()> {
+ let path = req.url();
+ match path {
+ "/" => handle_root(req, reload, bucket),
+ "/events" => handle_events(req, bucket.clone()),
+ _ => req.respond(Response::new_empty(StatusCode(404))),
+ }
+}
+
+/// Handles for the `/` route. Serves the compiled HTML.
+fn handle_root(req: Request, reload: bool, bucket: &Bucket<String>) -> io::Result<()> {
+ let mut html = bucket.get().clone();
+ if reload {
+ inject_live_reload_script(&mut html);
+ }
+ req.respond(Response::new(
+ StatusCode(200),
+ vec![Header::from_bytes("Content-Type", "text/html").unwrap()],
+ html.as_bytes(),
+ Some(html.len()),
+ None,
+ ))
+}
+
+/// Handler for the `/events` route.
+fn handle_events(req: Request, bucket: Arc<Bucket<String>>) -> io::Result<()> {
+ std::thread::spawn(move || {
+ // When this returns an error, the client is disconnected and we can
+ // terminate the thread.
+ let _ = handle_events_blocking(req, &bucket);
+ });
+ Ok(())
+}
+
+/// Event stream for the `/events` route.
+fn handle_events_blocking(req: Request, bucket: &Bucket<String>) -> io::Result<()> {
+ let mut writer = req.into_writer();
+ let writer: &mut dyn Write = &mut *writer;
+
+ // We need to write the header manually because `tiny-http` defaults to
+ // `Transfer-Encoding: chunked` when no `Content-Length` is provided, which
+ // Chrome & Safari dislike for `Content-Type: text/event-stream`.
+ write!(writer, "HTTP/1.1 200 OK\r\n")?;
+ write!(writer, "Content-Type: text/event-stream\r\n")?;
+ write!(writer, "Cache-Control: no-cache\r\n")?;
+ write!(writer, "\r\n")?;
+ writer.flush()?;
+
+ // If the user closes the browser tab, this loop will terminate once it
+ // tries to write to the dead socket for the first time.
+ loop {
+ bucket.wait();
+ // Trigger a server-sent event. The browser is listening to it via
+ // an `EventSource` listener` (see `inject_script`).
+ write!(writer, "event: reload\ndata:\n\n")?;
+ writer.flush()?;
+ }
+}
+
+/// Injects the live reload script into a string of HTML.
+fn inject_live_reload_script(html: &mut String) {
+ let pos = html.rfind("</html>").unwrap_or(html.len());
+ html.insert_str(pos, LIVE_RELOAD_SCRIPT);
+}
+
+/// Holds data and notifies consumers when it's updated.
+struct Bucket<T> {
+ mutex: Mutex<T>,
+ condvar: Condvar,
+}
+
+impl<T> Bucket<T> {
+ /// Creates a new bucket with initial data.
+ fn new(init: T) -> Self {
+ Self { mutex: Mutex::new(init), condvar: Condvar::new() }
+ }
+
+ /// Retrieves the current data in the bucket.
+ fn get(&self) -> MutexGuard<T> {
+ self.mutex.lock()
+ }
+
+ /// Puts new data into the bucket and notifies everyone who's currently
+ /// [waiting](Self::wait).
+ fn put(&self, data: T) {
+ *self.mutex.lock() = data;
+ self.condvar.notify_all();
+ }
+
+ /// Waits for new data in the bucket.
+ fn wait(&self) {
+ self.condvar.wait(&mut self.mutex.lock());
+ }
+}
+
+/// The initial HTML before compilation is finished.
+const PLACEHOLDER_HTML: &str = "\
+<html>
+ <head>
+ <title>Waiting for {INPUT}</title>
+ <style>
+ body {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: #565565;
+ background: #eff0f3;
+ }
+
+ body > main > div {
+ margin-block: 16px;
+ text-align: center;
+ }
+ </style>
+ </head>
+ <body>
+ <main>
+ <div>Waiting for output ...</div>
+ <div><code>typst watch {INPUT}</code></div>
+ </main>
+ </body>
+</html>
+";
+
+/// Reloads the page whenever it receives a "reload" server-sent event
+/// on the `/events` route.
+const LIVE_RELOAD_SCRIPT: &str = "\
+<script>\
+ new EventSource(\"/events\")\
+ .addEventListener(\"reload\", () => location.reload())\
+</script>\
+";
diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs
index f5569b46..e62746df 100644
--- a/crates/typst-cli/src/watch.rs
+++ b/crates/typst-cli/src/watch.rs
@@ -21,7 +21,7 @@ use crate::{print_error, terminal};
/// Execute a watching compilation command.
pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> {
- let mut config = CompileConfig::new(&command.args)?;
+ let mut config = CompileConfig::watching(command)?;
let Output::Path(output) = &config.output else {
bail!("cannot write document to stdout in watch mode");
@@ -53,7 +53,7 @@ pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> {
};
// Perform initial compilation.
- timer.record(&mut world, |world| compile_once(world, &mut config, true))??;
+ timer.record(&mut world, |world| compile_once(world, &mut config))??;
// Watch all dependencies of the initial compilation.
watcher.update(world.dependencies())?;
@@ -67,7 +67,7 @@ pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> {
world.reset();
// Recompile.
- timer.record(&mut world, |world| compile_once(world, &mut config, true))??;
+ timer.record(&mut world, |world| compile_once(world, &mut config))??;
// Evict the cache.
comemo::evict(10);
@@ -293,6 +293,13 @@ impl Status {
out.reset()?;
writeln!(out, " {}", config.output)?;
+ if let Some(server) = &config.server {
+ out.set_color(&color)?;
+ write!(out, "serving at")?;
+ out.reset()?;
+ writeln!(out, " http://{}", server.addr())?;
+ }
+
writeln!(out)?;
writeln!(out, "[{timestamp}] {}", self.message())?;
writeln!(out)?;
diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs
index c39358b9..af6cf228 100644
--- a/crates/typst-cli/src/world.rs
+++ b/crates/typst-cli/src/world.rs
@@ -18,7 +18,6 @@ use typst_kit::package::PackageStorage;
use typst_timing::timed;
use crate::args::{Feature, Input, ProcessArgs, WorldArgs};
-use crate::compile::ExportCache;
use crate::download::PrintDownload;
use crate::package;
@@ -49,9 +48,6 @@ pub struct SystemWorld {
/// always the same within one compilation.
/// Reset between compilations if not [`Now::Fixed`].
now: Now,
- /// The export cache, used for caching output files in `typst watch`
- /// sessions.
- export_cache: ExportCache,
}
impl SystemWorld {
@@ -146,7 +142,6 @@ impl SystemWorld {
slots: Mutex::new(HashMap::new()),
package_storage: package::storage(&world_args.package),
now,
- export_cache: ExportCache::new(),
})
}
@@ -191,11 +186,6 @@ impl SystemWorld {
pub fn lookup(&self, id: FileId) -> Source {
self.source(id).expect("file id does not point to any source file")
}
-
- /// Gets access to the export cache.
- pub fn export_cache(&self) -> &ExportCache {
- &self.export_cache
- }
}
impl World for SystemWorld {