diff --git a/Cargo.lock b/Cargo.lock index a4669ba80..a1193fe01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2660,6 +2660,7 @@ dependencies = [ name = "typst-docs" version = "0.10.0" dependencies = [ + "clap", "comemo", "ecow", "heck", @@ -2667,10 +2668,12 @@ dependencies = [ "once_cell", "pulldown-cmark", "serde", + "serde_json", "serde_yaml 0.9.27", "syntect", "typed-arena", "typst", + "typst-render", "unicode_names2", "unscanny", "yaml-front-matter", diff --git a/crates/typst-docs/Cargo.toml b/crates/typst-docs/Cargo.toml index 7b444b9b3..bb32aaf22 100644 --- a/crates/typst-docs/Cargo.toml +++ b/crates/typst-docs/Cargo.toml @@ -10,6 +10,13 @@ publish = false doctest = false bench = false +[[bin]] +name = "typst-docs" +required-features = ["cli"] + +[features] +cli = ["clap", "typst-render", "serde_json"] + [dependencies] typst = { workspace = true } comemo = { workspace = true } @@ -25,6 +32,9 @@ typed-arena = { workspace = true } unicode_names2 = { workspace = true } unscanny = { workspace = true } yaml-front-matter = { workspace = true } +clap = { workspace = true, optional = true } +typst-render = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } [lints] workspace = true diff --git a/crates/typst-docs/src/main.rs b/crates/typst-docs/src/main.rs new file mode 100644 index 000000000..f4414b109 --- /dev/null +++ b/crates/typst-docs/src/main.rs @@ -0,0 +1,148 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use clap::Parser; +use typst::model::Document; +use typst::visualize::Color; +use typst_docs::{provide, Html, Resolver}; +use typst_render::render; + +#[derive(Debug)] +struct CliResolver<'a> { + assets_dir: &'a Path, + verbose: bool, + base: &'a str, +} + +impl<'a> Resolver for CliResolver<'a> { + fn commits(&self, from: &str, to: &str) -> Vec { + if self.verbose { + eprintln!("commits({from}, {to})"); + } + vec![] + } + + fn example( + &self, + hash: u128, + source: Option, + document: &Document, + ) -> typst_docs::Html { + if self.verbose { + eprintln!( + "example(0x{hash:x}, {:?} chars, Document)", + source.as_ref().map(|s| s.as_str().len()) + ); + } + + let frame = &document.pages.first().expect("page 0").frame; + let pixmap = render(frame, 2.0, Color::WHITE); + let filename = format!("{hash:x}.png"); + let path = self.assets_dir.join(&filename); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + pixmap.save_png(path.as_path()).expect("save png"); + let src = format!("{}assets/{filename}", self.base); + eprintln!("Generated example image {path:?}"); + + if let Some(code) = source { + let code_safe = code.as_str(); + Html::new(format!( + r#"
{code_safe}
Preview
"# + )) + } else { + Html::new(format!( + r#"
Preview
"# + )) + } + } + + fn image(&self, filename: &str, data: &[u8]) -> String { + if self.verbose { + eprintln!("image({filename}, {} bytes)", data.len()); + } + + let path = self.assets_dir.join(filename); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + fs::write(&path, data).expect("write image"); + eprintln!("Created {} byte image at {path:?}", data.len()); + + format!("{}assets/{filename}", self.base) + } + + fn link(&self, link: &str) -> Option { + if self.verbose { + eprintln!("link({link})"); + } + None + } + + fn base(&self) -> &str { + self.base + } +} + +/// Generates the JSON representation of the documentation. This can be used to +/// generate the HTML yourself. Be warned: the JSON structure is not stable and +/// may change at any time. +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// The generation process can produce additional assets. Namely images. + /// This option controls where to spit them out. The HTML generation will + /// assume that this output directory is served at `${base_url}/assets/*`. + /// The default is `assets`. For example, if the base URL is `/docs/` then + /// the gemerated HTML might look like `` + /// even though the `--assets-dir` was set to `/tmp/images` or something. + #[arg(long, default_value = "assets")] + assets_dir: PathBuf, + + /// Write the JSON output to this file. The default is `-` which is a + /// special value that means "write to standard output". If you want to + /// write to a file named `-` then use `./-`. + #[arg(long, default_value = "-")] + out_file: PathBuf, + + /// The base URL for the documentation. This can be an absolute URL like + /// `https://example.com/docs/` or a relative URL like `/docs/`. This is + /// used as the base URL for the generated page's `.route` properties as + /// well as cross-page links. The default is `/`. If a `/` trailing slash is + /// not present then it will be added. This option also affects the HTML + /// asset references. For example: `--base /docs/` will generate + /// ``. + #[arg(long, default_value = "/")] + base: String, + + /// Enable verbose logging. This will print out all the calls to the + /// resolver and the paths of the generated assets. + #[arg(long)] + verbose: bool, +} + +fn main() -> Result<(), Box> { + let args = Args::parse(); + let mut base = args.base.clone(); + if !base.ends_with('/') { + base.push('/'); + } + + let resolver = CliResolver { + assets_dir: &args.assets_dir, + verbose: args.verbose, + base: &base, + }; + if args.verbose { + eprintln!("resolver: {resolver:?}"); + } + let pages = provide(&resolver); + + eprintln!("Be warned: the JSON structure is not stable and may change at any time."); + let json = serde_json::to_string_pretty(&pages)?; + + if args.out_file.to_string_lossy() == "-" { + println!("{json}"); + } else { + fs::write(&args.out_file, &*json)?; + } + + Ok(()) +}