diff options
| author | Laurenz <laurmaedje@gmail.com> | 2024-03-06 12:33:35 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-03-06 11:33:35 +0000 |
| commit | a558fd232b379e527eac2d5196eb9367b7d28032 (patch) | |
| tree | 1e91330df3b21bb86e067067cc9ff07ac8d513c8 /crates/typst-cli/src | |
| parent | 898367f096fef507488438e00caae8c4ea1d0ff4 (diff) | |
Add `typst init` command (#3544)
Diffstat (limited to 'crates/typst-cli/src')
| -rw-r--r-- | crates/typst-cli/src/args.rs | 18 | ||||
| -rw-r--r-- | crates/typst-cli/src/download.rs | 4 | ||||
| -rw-r--r-- | crates/typst-cli/src/init.rs | 114 | ||||
| -rw-r--r-- | crates/typst-cli/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/typst-cli/src/package.rs | 81 | ||||
| -rw-r--r-- | crates/typst-cli/src/update.rs | 6 | ||||
| -rw-r--r-- | crates/typst-cli/src/watch.rs | 30 | ||||
| -rw-r--r-- | crates/typst-cli/src/world.rs | 3 |
8 files changed, 219 insertions, 39 deletions
diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 973eea8b..71cbf515 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -46,6 +46,9 @@ pub enum Command { #[command(visible_alias = "w")] Watch(CompileCommand), + /// Initializes a new project from a template + Init(InitCommand), + /// Processes an input file to extract provided metadata Query(QueryCommand), @@ -89,6 +92,21 @@ pub struct CompileCommand { pub timings: Option<Option<PathBuf>>, } +/// Initializes a new project from a template +#[derive(Debug, Clone, Parser)] +pub struct InitCommand { + /// The template to use, e.g. `@preview/charged-ieee` + /// + /// You can specify the version by appending e.g. `:0.1.0`. If no version is + /// specified, Typst will default to the latest version. + /// + /// Supports both local and published templates. + pub template: String, + + /// The project directory, defaults to the template's name + pub dir: Option<String>, +} + /// Processes an input file to extract provided metadata #[derive(Debug, Clone, Parser)] pub struct QueryCommand { diff --git a/crates/typst-cli/src/download.rs b/crates/typst-cli/src/download.rs index 67411812..38b16008 100644 --- a/crates/typst-cli/src/download.rs +++ b/crates/typst-cli/src/download.rs @@ -180,12 +180,12 @@ impl RemoteReader { (remaining / speed) as u64 })); writeln!( - &mut terminal::out(), + terminal::out(), "{total_downloaded} / {download_size} ({percent:3.0} %) {speed_h} in {elapsed} ETA: {eta}", )?; } None => writeln!( - &mut terminal::out(), + terminal::out(), "Total downloaded: {total_downloaded} Speed: {speed_h} Elapsed: {elapsed}", )?, }; diff --git a/crates/typst-cli/src/init.rs b/crates/typst-cli/src/init.rs new file mode 100644 index 00000000..01fdb02f --- /dev/null +++ b/crates/typst-cli/src/init.rs @@ -0,0 +1,114 @@ +use std::io::Write; +use std::path::Path; + +use codespan_reporting::term::termcolor::{Color, ColorSpec, WriteColor}; +use ecow::eco_format; +use fs_extra::dir::CopyOptions; +use typst::diag::{bail, FileError, StrResult}; +use typst::syntax::package::{ + PackageManifest, PackageSpec, TemplateInfo, VersionlessPackageSpec, +}; + +use crate::args::InitCommand; + +/// Execute an initialization command. +pub fn init(command: &InitCommand) -> StrResult<()> { + // Parse the package specification. If the user didn't specify the version, + // we try to figure it out automatically by downloading the package index + // or searching the disk. + let spec: PackageSpec = command.template.parse().or_else(|err| { + // Try to parse without version, but prefer the error message of the + // normal package spec parsing if it fails. + let spec: VersionlessPackageSpec = command.template.parse().map_err(|_| err)?; + let version = crate::package::determine_latest_version(&spec)?; + StrResult::Ok(spec.at(version)) + })?; + + // Find or download the package. + let package_path = crate::package::prepare_package(&spec)?; + + // Parse the manifest. + let manifest = parse_manifest(&package_path)?; + manifest.validate(&spec)?; + + // Ensure that it is indeed a template. + let Some(template) = &manifest.template else { + bail!("package {spec} is not a template"); + }; + + // Determine the directory at which we will create the project. + let project_dir = Path::new(command.dir.as_deref().unwrap_or(&manifest.package.name)); + + // Set up the project. + scaffold_project(project_dir, &package_path, template)?; + + // Print the summary. + print_summary(spec, project_dir, template).unwrap(); + + Ok(()) +} + +/// Parses the manifest of the package located at `package_path`. +fn parse_manifest(package_path: &Path) -> StrResult<PackageManifest> { + let toml_path = package_path.join("typst.toml"); + let string = std::fs::read_to_string(&toml_path).map_err(|err| { + eco_format!( + "failed to read package manifest ({})", + FileError::from_io(err, &toml_path) + ) + })?; + + toml::from_str(&string) + .map_err(|err| eco_format!("package manifest is malformed ({})", err.message())) +} + +/// Creates the project directory with the template's contents and returns the +/// path at which it was created. +fn scaffold_project( + project_dir: &Path, + package_path: &Path, + template: &TemplateInfo, +) -> StrResult<()> { + if project_dir.exists() { + bail!("project directory already exists (at {})", project_dir.display()); + } + + let template_dir = package_path.join(template.path.as_str()); + if !template_dir.exists() { + bail!("template directory does not exist (at {})", template_dir.display()); + } + + fs_extra::dir::copy( + &template_dir, + project_dir, + &CopyOptions::new().content_only(true), + ) + .map_err(|err| eco_format!("failed to create project directory ({err})"))?; + + Ok(()) +} + +/// Prints a summary after successful initialization. +fn print_summary( + spec: PackageSpec, + project_dir: &Path, + template: &TemplateInfo, +) -> std::io::Result<()> { + let mut gray = ColorSpec::new(); + gray.set_fg(Some(Color::White)); + gray.set_dimmed(true); + + let mut out = crate::terminal::out(); + writeln!(out, "Successfully created new project from {spec} 🎉")?; + writeln!(out, "To start writing, run:")?; + out.set_color(&gray)?; + write!(out, "> ")?; + out.reset()?; + writeln!(out, "cd {}", project_dir.display())?; + out.set_color(&gray)?; + write!(out, "> ")?; + out.reset()?; + writeln!(out, "typst watch {}", template.entrypoint)?; + writeln!(out)?; + Ok(()) +} diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 71b3dd38..3f5ca2aa 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -2,6 +2,7 @@ mod args; mod compile; mod download; mod fonts; +mod init; mod package; mod query; mod terminal; @@ -39,6 +40,7 @@ fn main() -> ExitCode { let res = match &ARGS.command { Command::Compile(command) => crate::compile::compile(timer, command.clone()), Command::Watch(command) => crate::watch::watch(timer, command.clone()), + Command::Init(command) => crate::init::init(command), Command::Query(command) => crate::query::query(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 8141ad19..7d3f2264 100644 --- a/crates/typst-cli/src/package.rs +++ b/crates/typst-cli/src/package.rs @@ -5,12 +5,16 @@ use std::path::{Path, PathBuf}; use codespan_reporting::term::{self, termcolor}; use ecow::eco_format; use termcolor::WriteColor; -use typst::diag::{PackageError, PackageResult}; -use typst::syntax::PackageSpec; +use typst::diag::{bail, PackageError, PackageResult, StrResult}; +use typst::syntax::package::{ + PackageInfo, PackageSpec, PackageVersion, VersionlessPackageSpec, +}; -use crate::download::download_with_progress; +use crate::download::{download, download_with_progress}; use crate::terminal; +const HOST: &str = "https://packages.typst.org"; + /// Make a package available in the on-disk cache. pub fn prepare_package(spec: &PackageSpec) -> PackageResult<PathBuf> { let subdir = @@ -25,30 +29,59 @@ pub fn prepare_package(spec: &PackageSpec) -> PackageResult<PathBuf> { if let Some(cache_dir) = dirs::cache_dir() { let dir = cache_dir.join(&subdir); + if dir.exists() { + return Ok(dir); + } // Download from network if it doesn't exist yet. - if spec.namespace == "preview" && !dir.exists() { + if spec.namespace == "preview" { download_package(spec, &dir)?; - } - - if dir.exists() { - return Ok(dir); + if dir.exists() { + return Ok(dir); + } } } Err(PackageError::NotFound(spec.clone())) } +/// Try to determine the latest version of a package. +pub fn determine_latest_version( + spec: &VersionlessPackageSpec, +) -> StrResult<PackageVersion> { + if spec.namespace == "preview" { + // For `@preview`, download the package index and find the latest + // version. + download_index()? + .iter() + .filter(|package| package.name == spec.name) + .map(|package| package.version) + .max() + .ok_or_else(|| eco_format!("failed to find package {spec}")) + } else { + // For other namespaces, search locally. We only search in the data + // directory and not the cache directory, because the latter is not + // intended for storage of local packages. + let subdir = format!("typst/packages/{}/{}", spec.namespace, spec.name); + dirs::data_dir() + .into_iter() + .flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok()) + .flatten() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter_map(|path| path.file_name()?.to_string_lossy().parse().ok()) + .max() + .ok_or_else(|| eco_format!("please specify the desired version")) + } +} + /// Download a package over the network. fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> { // The `@preview` namespace is the only namespace that supports on-demand // fetching. assert_eq!(spec.namespace, "preview"); - let url = format!( - "https://packages.typst.org/preview/{}-{}.tar.gz", - spec.name, spec.version - ); + let url = format!("{HOST}/preview/{}-{}.tar.gz", spec.name, spec.version); print_downloading(spec).unwrap(); @@ -67,14 +100,28 @@ fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> }) } +/// Download the `@preview` package index. +fn download_index() -> StrResult<Vec<PackageInfo>> { + let url = format!("{HOST}/preview/index.json"); + match download(&url) { + Ok(response) => response + .into_json() + .map_err(|err| eco_format!("failed to parse package index: {err}")), + Err(ureq::Error::Status(404, _)) => { + bail!("failed to fetch package index (not found)") + } + Err(err) => bail!("failed to fetch package index ({err})"), + } +} + /// Print that a package downloading is happening. fn print_downloading(spec: &PackageSpec) -> io::Result<()> { let styles = term::Styles::default(); - let mut term_out = terminal::out(); - term_out.set_color(&styles.header_help)?; - write!(term_out, "downloading")?; + let mut out = terminal::out(); + out.set_color(&styles.header_help)?; + write!(out, "downloading")?; - term_out.reset()?; - writeln!(term_out, " {spec}") + out.reset()?; + writeln!(out, " {spec}") } diff --git a/crates/typst-cli/src/update.rs b/crates/typst-cli/src/update.rs index cfb93454..b33e0519 100644 --- a/crates/typst-cli/src/update.rs +++ b/crates/typst-cli/src/update.rs @@ -110,9 +110,9 @@ impl Release { }; match download(&url) { - Ok(response) => response - .into_json() - .map_err(|err| eco_format!("unable to parse JSON response: {err}")), + Ok(response) => response.into_json().map_err(|err| { + eco_format!("failed to parse release information ({err})") + }), Err(ureq::Error::Status(404, _)) => { bail!("release not found (searched at {url})") } diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index be2fa867..14ab2efd 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -273,27 +273,27 @@ impl Status { let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); let color = self.color(); - let mut term_out = terminal::out(); - term_out.clear_screen()?; + let mut out = terminal::out(); + out.clear_screen()?; - term_out.set_color(&color)?; - write!(term_out, "watching")?; - term_out.reset()?; + out.set_color(&color)?; + write!(out, "watching")?; + out.reset()?; match &command.common.input { - Input::Stdin => writeln!(term_out, " <stdin>"), - Input::Path(path) => writeln!(term_out, " {}", path.display()), + Input::Stdin => writeln!(out, " <stdin>"), + Input::Path(path) => writeln!(out, " {}", path.display()), }?; - term_out.set_color(&color)?; - write!(term_out, "writing to")?; - term_out.reset()?; - writeln!(term_out, " {}", output.display())?; + out.set_color(&color)?; + write!(out, "writing to")?; + out.reset()?; + writeln!(out, " {}", output.display())?; - writeln!(term_out)?; - writeln!(term_out, "[{timestamp}] {}", self.message())?; - writeln!(term_out)?; + writeln!(out)?; + writeln!(out, "[{timestamp}] {}", self.message())?; + writeln!(out)?; - term_out.flush() + out.flush() } fn message(&self) -> String { diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 26bca325..3c4b8be6 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -19,7 +19,6 @@ use typst_timing::{timed, TimingScope}; use crate::args::{Input, SharedArgs}; use crate::compile::ExportCache; use crate::fonts::{FontSearcher, FontSlot}; -use crate::package::prepare_package; /// Static `FileId` allocated for stdin. /// This is to ensure that a file is read in the correct way. @@ -344,7 +343,7 @@ fn system_path(project_root: &Path, id: FileId) -> FileResult<PathBuf> { let buf; let mut root = project_root; if let Some(spec) = id.package() { - buf = prepare_package(spec)?; + buf = crate::package::prepare_package(spec)?; root = &buf; } |
