summaryrefslogtreecommitdiff
path: root/crates/typst-cli/src
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2024-03-06 12:33:35 +0100
committerGitHub <noreply@github.com>2024-03-06 11:33:35 +0000
commita558fd232b379e527eac2d5196eb9367b7d28032 (patch)
tree1e91330df3b21bb86e067067cc9ff07ac8d513c8 /crates/typst-cli/src
parent898367f096fef507488438e00caae8c4ea1d0ff4 (diff)
Add `typst init` command (#3544)
Diffstat (limited to 'crates/typst-cli/src')
-rw-r--r--crates/typst-cli/src/args.rs18
-rw-r--r--crates/typst-cli/src/download.rs4
-rw-r--r--crates/typst-cli/src/init.rs114
-rw-r--r--crates/typst-cli/src/main.rs2
-rw-r--r--crates/typst-cli/src/package.rs81
-rw-r--r--crates/typst-cli/src/update.rs6
-rw-r--r--crates/typst-cli/src/watch.rs30
-rw-r--r--crates/typst-cli/src/world.rs3
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;
}