diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index fd0eb5f05..a2fb10fa2 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -75,6 +75,9 @@ pub enum Command { /// Processes an input file to extract provided metadata. Query(QueryCommand), + /// Create a vendor directory with all used packages. + Vendor(VendorCommand), + /// Lists all discovered fonts in system and custom font paths. Fonts(FontsCommand), @@ -160,6 +163,22 @@ pub struct QueryCommand { pub process: ProcessArgs, } +/// Create a vendor directory with all used packages in the current directory. +#[derive(Debug, Clone, Parser)] +pub struct VendorCommand { + /// Path to input Typst file. Use `-` to read input from stdin. + #[clap(value_parser = input_value_parser(), value_hint = ValueHint::FilePath)] + pub input: Input, + + /// World arguments. + #[clap(flatten)] + pub world: WorldArgs, + + /// Processing arguments. + #[clap(flatten)] + pub process: ProcessArgs, +} + /// Lists all discovered fonts in system and custom font paths. #[derive(Debug, Clone, Parser)] pub struct FontsCommand { @@ -342,6 +361,14 @@ pub struct PackageArgs { value_name = "DIR" )] pub package_cache_path: Option, + + /// Custom vendor directory name. + #[clap( + long = "package-vendor-path", + env = "TYPST_PACKAGE_VENDOR_PATH", + value_name = "DIR" + )] + pub vendor_path: Option, } /// Common arguments to customize available fonts. diff --git a/crates/typst-cli/src/init.rs b/crates/typst-cli/src/init.rs index 9a77fb470..f7bdb9d1a 100644 --- a/crates/typst-cli/src/init.rs +++ b/crates/typst-cli/src/init.rs @@ -15,7 +15,7 @@ use crate::package; /// Execute an initialization command. pub fn init(command: &InitCommand) -> StrResult<()> { - let package_storage = package::storage(&command.package); + let package_storage = package::storage(&command.package, None); // Parse the package specification. If the user didn't specify the version, // we try to figure it out automatically by downloading the package index diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 14f8a665d..4e313552e 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -12,6 +12,7 @@ mod terminal; mod timings; #[cfg(feature = "self-update")] mod update; +mod vendor; mod watch; mod world; @@ -69,6 +70,7 @@ fn dispatch() -> HintedStrResult<()> { Command::Watch(command) => crate::watch::watch(&mut timer, command)?, Command::Init(command) => crate::init::init(command)?, Command::Query(command) => crate::query::query(command)?, + Command::Vendor(command) => crate::vendor::vendor(command)?, Command::Fonts(command) => crate::fonts::fonts(command), Command::Update(command) => crate::update::update(command)?, } diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs index 6099ecaa9..48e95e97f 100644 --- a/crates/typst-cli/src/package.rs +++ b/crates/typst-cli/src/package.rs @@ -1,13 +1,17 @@ +use std::path::PathBuf; + use typst_kit::package::PackageStorage; use crate::args::PackageArgs; use crate::download; /// Returns a new package storage for the given args. -pub fn storage(args: &PackageArgs) -> PackageStorage { +pub fn storage(args: &PackageArgs, workdir: Option) -> PackageStorage { PackageStorage::new( + args.vendor_path.clone(), args.package_cache_path.clone(), args.package_path.clone(), download::downloader(), + workdir, ) } diff --git a/crates/typst-cli/src/vendor.rs b/crates/typst-cli/src/vendor.rs new file mode 100644 index 000000000..41300e6e1 --- /dev/null +++ b/crates/typst-cli/src/vendor.rs @@ -0,0 +1,109 @@ +use std::{ + fs::{create_dir, create_dir_all}, + path::PathBuf, +}; + +use ecow::eco_format; +use typst::{ + diag::{bail, HintedStrResult, Warned}, + layout::PagedDocument, +}; +use typst_kit::package::{DEFAULT_PACKAGES_SUBDIR, DEFAULT_VENDOR_SUBDIR}; + +use crate::{ + args::VendorCommand, compile::print_diagnostics, set_failed, world::SystemWorld, +}; +use typst::World; + +/// Execute a vendor command. +pub fn vendor(command: &VendorCommand) -> HintedStrResult<()> { + let mut world = SystemWorld::new(&command.input, &command.world, &command.process)?; + + // Reset everything and ensure that the main file is present. + world.reset(); + world.source(world.main()).map_err(|err| err.to_string())?; + + let Warned { output, warnings } = typst::compile::(&world); + + match output { + Ok(_) => { + copy_deps(&mut world, &command.world.package.vendor_path)?; + print_diagnostics(&world, &[], &warnings, command.process.diagnostic_format) + .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; + } + + // Print diagnostics. + Err(errors) => { + set_failed(); + print_diagnostics( + &world, + &errors, + &warnings, + command.process.diagnostic_format, + ) + .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; + } + } + + Ok(()) +} + +fn copy_deps( + world: &mut SystemWorld, + vendor_path: &Option, +) -> HintedStrResult<()> { + let vendor_dir = match vendor_path { + Some(path) => match path.canonicalize() { + Ok(path) => path, + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + if let Err(err) = create_dir(path) { + bail!("failed to create vendor directory: {:?}", err); + } + path.clone() + } else { + bail!("failed to canonicalize vendor directory path: {:?}", err); + } + } + }, + None => world.workdir().join(DEFAULT_VENDOR_SUBDIR), + }; + + // Must iterate two times in total. As soon as the parent directory is created, + // world tries to read the subsequent files from the same package + // from the vendor directory since it is higher priority. + let all_deps = world + .dependencies() + .filter_map(|dep_path| { + let path = dep_path.to_str().unwrap(); + path.find(DEFAULT_PACKAGES_SUBDIR).map(|pos| { + let dependency_path = &path[pos + DEFAULT_PACKAGES_SUBDIR.len() + 1..]; + (dep_path.clone(), vendor_dir.join(dependency_path)) + }) + }) + .collect::>(); + + for (from_data_path, to_vendor_path) in all_deps { + if let Some(parent) = to_vendor_path.parent() { + match parent.try_exists() { + Ok(false) => { + if let Err(err) = create_dir_all(parent) { + bail!( + "failed to create package inside the vendor directory: {:?}", + err + ); + } + } + Err(err) => { + bail!("failed to check existence of a package inside the vendor directory: {:?}", err); + } + _ => {} + } + } + + if let Err(err) = std::fs::copy(from_data_path, to_vendor_path) { + bail!("failed to copy dependency to vendor directory: {:?}", err); + } + } + Ok(()) +} diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 2da03d4d5..4c7896afe 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -29,7 +29,7 @@ static STDIN_ID: LazyLock = /// A world that provides access to the operating system. pub struct SystemWorld { /// The working directory. - workdir: Option, + workdir: PathBuf, /// The root relative to which absolute paths are resolved. root: PathBuf, /// The input path. @@ -132,15 +132,18 @@ impl SystemWorld { None => Now::System(OnceLock::new()), }; + let env_workdir = std::env::current_dir().ok(); + let workdir = env_workdir.unwrap_or(PathBuf::from(".")); + Ok(Self { - workdir: std::env::current_dir().ok(), + workdir: workdir.clone(), root, main, library: LazyHash::new(library), book: LazyHash::new(fonts.book), fonts: fonts.fonts, slots: Mutex::new(HashMap::new()), - package_storage: package::storage(&world_args.package), + package_storage: package::storage(&world_args.package, Some(workdir)), now, }) } @@ -157,7 +160,7 @@ impl SystemWorld { /// The current working directory. pub fn workdir(&self) -> &Path { - self.workdir.as_deref().unwrap_or(Path::new(".")) + self.workdir.as_path() } /// Return all paths the last compilation depended on. diff --git a/crates/typst-kit/src/package.rs b/crates/typst-kit/src/package.rs index 584ec83c0..7a1b3bc82 100644 --- a/crates/typst-kit/src/package.rs +++ b/crates/typst-kit/src/package.rs @@ -21,10 +21,15 @@ pub const DEFAULT_NAMESPACE: &str = "preview"; /// The default packages sub directory within the package and package cache paths. pub const DEFAULT_PACKAGES_SUBDIR: &str = "typst/packages"; +/// The default vendor sub directory within the project root. +pub const DEFAULT_VENDOR_SUBDIR: &str = "vendor"; + /// Holds information about where packages should be stored and downloads them /// on demand, if possible. #[derive(Debug)] pub struct PackageStorage { + /// The path at which packages are stored by the vendor command. + package_vendor_path: Option, /// The path at which non-local packages should be stored when downloaded. package_cache_path: Option, /// The path at which local packages are stored. @@ -39,9 +44,11 @@ impl PackageStorage { /// Creates a new package storage for the given package paths. Falls back to /// the recommended XDG directories if they are `None`. pub fn new( + package_vendor_path: Option, package_cache_path: Option, package_path: Option, downloader: Downloader, + workdir: Option, ) -> Self { Self::with_index(package_cache_path, package_path, downloader, OnceCell::new()) } @@ -56,6 +63,8 @@ impl PackageStorage { index: OnceCell>, ) -> Self { Self { + package_vendor_path: package_vendor_path + .or_else(|| workdir.map(|workdir| workdir.join(DEFAULT_VENDOR_SUBDIR))), package_cache_path: package_cache_path.or_else(|| { dirs::cache_dir().map(|cache_dir| cache_dir.join(DEFAULT_PACKAGES_SUBDIR)) }), @@ -87,6 +96,16 @@ impl PackageStorage { ) -> PackageResult { let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version); + // Read from vendor dir if it exists. + if let Some(vendor_dir) = &self.package_vendor_path { + if let Ok(true) = vendor_dir.try_exists() { + let dir = vendor_dir.join(&subdir); + if dir.exists() { + return Ok(dir); + } + } + } + if let Some(packages_dir) = &self.package_path { let dir = packages_dir.join(&subdir); if dir.exists() {