mirror of
https://github.com/typst/typst
synced 2025-05-14 04:56:26 +08:00
Live reloading HTTP server for typst watch
and HTML export (#5524)
This commit is contained in:
parent
4f3ba7f8ca
commit
79a7a6bf77
36
Cargo.lock
generated
36
Cargo.lock
generated
@ -123,6 +123,12 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@ -282,6 +288,12 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chunked_transfer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ciborium"
|
name = "ciborium"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@ -909,6 +921,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hypher"
|
name = "hypher"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -1296,13 +1314,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libfuzzer-sys"
|
name = "libfuzzer-sys"
|
||||||
version = "0.4.7"
|
version = "0.4.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7"
|
checksum = "9b9569d2f74e257076d8c6bfa73fb505b46b851e51ddaecc825944aa3bed17fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"cc",
|
"cc",
|
||||||
"once_cell",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2581,6 +2598,18 @@ dependencies = [
|
|||||||
"strict-num",
|
"strict-num",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny_http"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"chunked_transfer",
|
||||||
|
"httpdate",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@ -2718,6 +2747,7 @@ dependencies = [
|
|||||||
"sigpipe",
|
"sigpipe",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"tiny_http",
|
||||||
"toml",
|
"toml",
|
||||||
"typst",
|
"typst",
|
||||||
"typst-eval",
|
"typst-eval",
|
||||||
|
@ -117,6 +117,7 @@ tar = "0.4"
|
|||||||
tempfile = "3.7.0"
|
tempfile = "3.7.0"
|
||||||
thin-vec = "0.2.13"
|
thin-vec = "0.2.13"
|
||||||
time = { version = "0.3.20", features = ["formatting", "macros", "parsing"] }
|
time = { version = "0.3.20", features = ["formatting", "macros", "parsing"] }
|
||||||
|
tiny_http = "0.12"
|
||||||
tiny-skia = "0.11"
|
tiny-skia = "0.11"
|
||||||
toml = { version = "0.8", default-features = false, features = ["parse", "display"] }
|
toml = { version = "0.8", default-features = false, features = ["parse", "display"] }
|
||||||
ttf-parser = "0.24.1"
|
ttf-parser = "0.24.1"
|
||||||
|
@ -50,6 +50,7 @@ shell-escape = { workspace = true }
|
|||||||
sigpipe = { workspace = true }
|
sigpipe = { workspace = true }
|
||||||
tar = { workspace = true }
|
tar = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
tiny_http = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
ureq = { workspace = true }
|
ureq = { workspace = true }
|
||||||
xz2 = { workspace = true, optional = true }
|
xz2 = { workspace = true, optional = true }
|
||||||
|
@ -97,6 +97,21 @@ pub struct WatchCommand {
|
|||||||
/// Arguments for compilation.
|
/// Arguments for compilation.
|
||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
pub args: CompileArgs,
|
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.
|
/// Initializes a new project from a template.
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
use std::ffi::OsString;
|
||||||
use std::fs::{self, File};
|
use std::fs::{self, File};
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@ -20,8 +21,9 @@ use typst_pdf::{PdfOptions, PdfStandards};
|
|||||||
|
|
||||||
use crate::args::{
|
use crate::args::{
|
||||||
CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat,
|
CompileArgs, CompileCommand, DiagnosticFormat, Input, Output, OutputFormat,
|
||||||
PdfStandard,
|
PdfStandard, WatchCommand,
|
||||||
};
|
};
|
||||||
|
use crate::server::HtmlServer;
|
||||||
use crate::timings::Timer;
|
use crate::timings::Timer;
|
||||||
|
|
||||||
use crate::watch::Status;
|
use crate::watch::Status;
|
||||||
@ -33,15 +35,17 @@ type CodespanError = codespan_reporting::files::Error;
|
|||||||
|
|
||||||
/// Execute a compilation command.
|
/// Execute a compilation command.
|
||||||
pub fn compile(timer: &mut Timer, command: &CompileCommand) -> StrResult<()> {
|
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 =
|
let mut world =
|
||||||
SystemWorld::new(&command.args.input, &command.args.world, &command.args.process)
|
SystemWorld::new(&command.args.input, &command.args.world, &command.args.process)
|
||||||
.map_err(|err| eco_format!("{err}"))?;
|
.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`.
|
/// A preprocessed `CompileCommand`.
|
||||||
pub struct CompileConfig {
|
pub struct CompileConfig {
|
||||||
|
/// Whether we are watching.
|
||||||
|
pub watching: bool,
|
||||||
/// Path to input Typst file or stdin.
|
/// Path to input Typst file or stdin.
|
||||||
pub input: Input,
|
pub input: Input,
|
||||||
/// Path to output file (PDF, PNG, SVG, or HTML).
|
/// Path to output file (PDF, PNG, SVG, or HTML).
|
||||||
@ -64,11 +68,27 @@ pub struct CompileConfig {
|
|||||||
pub make_deps: Option<PathBuf>,
|
pub make_deps: Option<PathBuf>,
|
||||||
/// The PPI (pixels per inch) to use for PNG export.
|
/// The PPI (pixels per inch) to use for PNG export.
|
||||||
pub ppi: f32,
|
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 {
|
impl CompileConfig {
|
||||||
/// Preprocess a `CompileCommand`, producing a compilation config.
|
/// 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 input = args.input.clone();
|
||||||
|
|
||||||
let output_format = if let Some(specified) = args.format {
|
let output_format = if let Some(specified) = args.format {
|
||||||
@ -119,7 +139,17 @@ impl CompileConfig {
|
|||||||
PdfStandards::new(&list)?
|
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 {
|
Ok(Self {
|
||||||
|
watching,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
output_format,
|
output_format,
|
||||||
@ -130,6 +160,8 @@ impl CompileConfig {
|
|||||||
ppi: args.ppi,
|
ppi: args.ppi,
|
||||||
diagnostic_format: args.process.diagnostic_format,
|
diagnostic_format: args.process.diagnostic_format,
|
||||||
open: args.open.clone(),
|
open: args.open.clone(),
|
||||||
|
export_cache: ExportCache::new(),
|
||||||
|
server,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,21 +173,20 @@ impl CompileConfig {
|
|||||||
pub fn compile_once(
|
pub fn compile_once(
|
||||||
world: &mut SystemWorld,
|
world: &mut SystemWorld,
|
||||||
config: &mut CompileConfig,
|
config: &mut CompileConfig,
|
||||||
watching: bool,
|
|
||||||
) -> StrResult<()> {
|
) -> StrResult<()> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
if watching {
|
if config.watching {
|
||||||
Status::Compiling.print(config).unwrap();
|
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 {
|
match output {
|
||||||
// Export the PDF / PNG.
|
// Export the PDF / PNG.
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let duration = start.elapsed();
|
let duration = start.elapsed();
|
||||||
|
|
||||||
if watching {
|
if config.watching {
|
||||||
if warnings.is_empty() {
|
if warnings.is_empty() {
|
||||||
Status::Success(duration).print(config).unwrap();
|
Status::Success(duration).print(config).unwrap();
|
||||||
} else {
|
} else {
|
||||||
@ -167,19 +198,14 @@ pub fn compile_once(
|
|||||||
.map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
|
.map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
|
||||||
|
|
||||||
write_make_deps(world, config)?;
|
write_make_deps(world, config)?;
|
||||||
|
open_output(config)?;
|
||||||
if let Some(open) = config.open.take() {
|
|
||||||
if let Output::Path(file) = &config.output {
|
|
||||||
open_file(open.as_deref(), file)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print diagnostics.
|
// Print diagnostics.
|
||||||
Err(errors) => {
|
Err(errors) => {
|
||||||
set_failed();
|
set_failed();
|
||||||
|
|
||||||
if watching {
|
if config.watching {
|
||||||
Status::Error.print(config).unwrap();
|
Status::Error.print(config).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,48 +217,48 @@ pub fn compile_once(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compile and then export the document.
|
||||||
fn compile_and_export(
|
fn compile_and_export(
|
||||||
world: &mut SystemWorld,
|
world: &mut SystemWorld,
|
||||||
config: &mut CompileConfig,
|
config: &mut CompileConfig,
|
||||||
watching: bool,
|
|
||||||
) -> Warned<SourceResult<()>> {
|
) -> Warned<SourceResult<()>> {
|
||||||
match config.output_format {
|
match config.output_format {
|
||||||
OutputFormat::Html => {
|
OutputFormat::Html => {
|
||||||
let Warned { output, warnings } = typst::compile::<HtmlDocument>(world);
|
let Warned { output, warnings } = typst::compile::<HtmlDocument>(world);
|
||||||
let result = output.and_then(|document| {
|
let result = output.and_then(|document| export_html(&document, config));
|
||||||
config
|
|
||||||
.output
|
|
||||||
.write(typst_html::html(&document)?.as_bytes())
|
|
||||||
.map_err(|err| eco_format!("failed to write HTML file ({err})"))
|
|
||||||
.at(Span::detached())
|
|
||||||
});
|
|
||||||
Warned { output: result, warnings }
|
Warned { output: result, warnings }
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let Warned { output, warnings } = typst::compile::<PagedDocument>(world);
|
let Warned { output, warnings } = typst::compile::<PagedDocument>(world);
|
||||||
let result = output
|
let result = output.and_then(|document| export_paged(&document, config));
|
||||||
.and_then(|document| export_paged(world, &document, config, watching));
|
|
||||||
Warned { output: result, warnings }
|
Warned { output: result, warnings }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export into the target format.
|
/// Export to HTML.
|
||||||
fn export_paged(
|
fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> {
|
||||||
world: &mut SystemWorld,
|
let html = typst_html::html(document)?;
|
||||||
document: &PagedDocument,
|
let result = config.output.write(html.as_bytes());
|
||||||
config: &CompileConfig,
|
|
||||||
watching: bool,
|
if let Some(server) = &config.server {
|
||||||
) -> SourceResult<()> {
|
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 {
|
match config.output_format {
|
||||||
OutputFormat::Pdf => export_pdf(document, config),
|
OutputFormat::Pdf => export_pdf(document, config),
|
||||||
OutputFormat::Png => {
|
OutputFormat::Png => {
|
||||||
export_image(world, document, config, watching, ImageExportFormat::Png)
|
export_image(document, config, ImageExportFormat::Png).at(Span::detached())
|
||||||
.at(Span::detached())
|
|
||||||
}
|
}
|
||||||
OutputFormat::Svg => {
|
OutputFormat::Svg => {
|
||||||
export_image(world, document, config, watching, ImageExportFormat::Svg)
|
export_image(document, config, ImageExportFormat::Svg).at(Span::detached())
|
||||||
.at(Span::detached())
|
|
||||||
}
|
}
|
||||||
OutputFormat::Html => unreachable!(),
|
OutputFormat::Html => unreachable!(),
|
||||||
}
|
}
|
||||||
@ -278,10 +304,8 @@ enum ImageExportFormat {
|
|||||||
|
|
||||||
/// Export to one or multiple images.
|
/// Export to one or multiple images.
|
||||||
fn export_image(
|
fn export_image(
|
||||||
world: &mut SystemWorld,
|
|
||||||
document: &PagedDocument,
|
document: &PagedDocument,
|
||||||
config: &CompileConfig,
|
config: &CompileConfig,
|
||||||
watching: bool,
|
|
||||||
fmt: ImageExportFormat,
|
fmt: ImageExportFormat,
|
||||||
) -> StrResult<()> {
|
) -> StrResult<()> {
|
||||||
// Determine whether we have indexable templates in output
|
// Determine whether we have indexable templates in output
|
||||||
@ -313,8 +337,6 @@ fn export_image(
|
|||||||
bail!("cannot export multiple images {err}");
|
bail!("cannot export multiple images {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cache = world.export_cache();
|
|
||||||
|
|
||||||
// The results are collected in a `Vec<()>` which does not allocate.
|
// The results are collected in a `Vec<()>` which does not allocate.
|
||||||
exported_pages
|
exported_pages
|
||||||
.par_iter()
|
.par_iter()
|
||||||
@ -337,7 +359,10 @@ fn export_image(
|
|||||||
// If we are not watching, don't use the cache.
|
// If we are not watching, don't use the cache.
|
||||||
// If the frame is in the cache, skip it.
|
// If the frame is in the cache, skip it.
|
||||||
// If the file does not exist, always create 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(());
|
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 default file viewer if `open` is `None`.
|
||||||
/// - The given viewer provided by `open` if it is `Some`.
|
/// - The given viewer provided by `open` if it is `Some`.
|
||||||
///
|
///
|
||||||
/// If the file could not be opened, an error is returned.
|
/// If the file could not be opened, an error is returned.
|
||||||
fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> {
|
fn open_output(config: &mut CompileConfig) -> StrResult<()> {
|
||||||
// Some resource openers require the path to be canonicalized.
|
let Some(open) = config.open.take() else { return Ok(()) };
|
||||||
let path = path
|
|
||||||
.canonicalize()
|
let path = if let Some(server) = &config.server {
|
||||||
.map_err(|err| eco_format!("failed to canonicalize path ({err})"))?;
|
OsString::from(format!("http://{}", server.addr()))
|
||||||
if let Some(app) = open {
|
} 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)
|
open::with_detached(&path, app)
|
||||||
.map_err(|err| eco_format!("failed to open file with {} ({})", app, err))
|
.map_err(|err| eco_format!("failed to open file with {} ({})", app, err))
|
||||||
} else {
|
} else {
|
||||||
|
@ -6,6 +6,7 @@ mod greet;
|
|||||||
mod init;
|
mod init;
|
||||||
mod package;
|
mod package;
|
||||||
mod query;
|
mod query;
|
||||||
|
mod server;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
mod timings;
|
mod timings;
|
||||||
#[cfg(feature = "self-update")]
|
#[cfg(feature = "self-update")]
|
||||||
|
217
crates/typst-cli/src/server.rs
Normal file
217
crates/typst-cli/src/server.rs
Normal file
@ -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>\
|
||||||
|
";
|
@ -21,7 +21,7 @@ use crate::{print_error, terminal};
|
|||||||
|
|
||||||
/// Execute a watching compilation command.
|
/// Execute a watching compilation command.
|
||||||
pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> {
|
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 {
|
let Output::Path(output) = &config.output else {
|
||||||
bail!("cannot write document to stdout in watch mode");
|
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.
|
// 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.
|
// Watch all dependencies of the initial compilation.
|
||||||
watcher.update(world.dependencies())?;
|
watcher.update(world.dependencies())?;
|
||||||
@ -67,7 +67,7 @@ pub fn watch(timer: &mut Timer, command: &WatchCommand) -> StrResult<()> {
|
|||||||
world.reset();
|
world.reset();
|
||||||
|
|
||||||
// Recompile.
|
// 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.
|
// Evict the cache.
|
||||||
comemo::evict(10);
|
comemo::evict(10);
|
||||||
@ -293,6 +293,13 @@ impl Status {
|
|||||||
out.reset()?;
|
out.reset()?;
|
||||||
writeln!(out, " {}", config.output)?;
|
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)?;
|
||||||
writeln!(out, "[{timestamp}] {}", self.message())?;
|
writeln!(out, "[{timestamp}] {}", self.message())?;
|
||||||
writeln!(out)?;
|
writeln!(out)?;
|
||||||
|
@ -18,7 +18,6 @@ use typst_kit::package::PackageStorage;
|
|||||||
use typst_timing::timed;
|
use typst_timing::timed;
|
||||||
|
|
||||||
use crate::args::{Feature, Input, ProcessArgs, WorldArgs};
|
use crate::args::{Feature, Input, ProcessArgs, WorldArgs};
|
||||||
use crate::compile::ExportCache;
|
|
||||||
use crate::download::PrintDownload;
|
use crate::download::PrintDownload;
|
||||||
use crate::package;
|
use crate::package;
|
||||||
|
|
||||||
@ -49,9 +48,6 @@ pub struct SystemWorld {
|
|||||||
/// always the same within one compilation.
|
/// always the same within one compilation.
|
||||||
/// Reset between compilations if not [`Now::Fixed`].
|
/// Reset between compilations if not [`Now::Fixed`].
|
||||||
now: Now,
|
now: Now,
|
||||||
/// The export cache, used for caching output files in `typst watch`
|
|
||||||
/// sessions.
|
|
||||||
export_cache: ExportCache,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemWorld {
|
impl SystemWorld {
|
||||||
@ -146,7 +142,6 @@ impl SystemWorld {
|
|||||||
slots: Mutex::new(HashMap::new()),
|
slots: Mutex::new(HashMap::new()),
|
||||||
package_storage: package::storage(&world_args.package),
|
package_storage: package::storage(&world_args.package),
|
||||||
now,
|
now,
|
||||||
export_cache: ExportCache::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,11 +186,6 @@ impl SystemWorld {
|
|||||||
pub fn lookup(&self, id: FileId) -> Source {
|
pub fn lookup(&self, id: FileId) -> Source {
|
||||||
self.source(id).expect("file id does not point to any source file")
|
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 {
|
impl World for SystemWorld {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user