From a518e2dd4d829b45b0887da28acb77d0568894ab Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 28 Feb 2024 11:37:52 +0100 Subject: Move docs generation code (#3519) --- Cargo.lock | 20 - Cargo.toml | 3 +- crates/typst-docs/Cargo.toml | 42 -- crates/typst-docs/src/contribs.rs | 86 ---- crates/typst-docs/src/html.rs | 469 --------------------- crates/typst-docs/src/lib.rs | 840 -------------------------------------- crates/typst-docs/src/link.rs | 108 ----- crates/typst-docs/src/main.rs | 148 ------- crates/typst-docs/src/model.rs | 173 -------- docs/Cargo.toml | 41 ++ docs/src/contribs.rs | 86 ++++ docs/src/html.rs | 469 +++++++++++++++++++++ docs/src/lib.rs | 803 ++++++++++++++++++++++++++++++++++++ docs/src/link.rs | 108 +++++ docs/src/main.rs | 148 +++++++ docs/src/model.rs | 173 ++++++++ docs/tutorial/1-writing.md | 4 +- docs/tutorial/2-formatting.md | 2 +- docs/tutorial/3-advanced.md | 4 +- 19 files changed, 1834 insertions(+), 1893 deletions(-) delete mode 100644 crates/typst-docs/Cargo.toml delete mode 100644 crates/typst-docs/src/contribs.rs delete mode 100644 crates/typst-docs/src/html.rs delete mode 100644 crates/typst-docs/src/lib.rs delete mode 100644 crates/typst-docs/src/link.rs delete mode 100644 crates/typst-docs/src/main.rs delete mode 100644 crates/typst-docs/src/model.rs create mode 100644 docs/Cargo.toml create mode 100644 docs/src/contribs.rs create mode 100644 docs/src/html.rs create mode 100644 docs/src/lib.rs create mode 100644 docs/src/link.rs create mode 100644 docs/src/main.rs create mode 100644 docs/src/model.rs diff --git a/Cargo.lock b/Cargo.lock index 3f9d1e08..d512d166 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1050,25 +1050,6 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" -[[package]] -name = "include_dir" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18762faeff7122e89e0857b02f7ce6fcc0d101d5e9ad2ad7846cc01d61b7f19e" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b139284b5cf57ecfa712bcc66950bb635b31aff41c188e8a4cfc758eca374a3f" -dependencies = [ - "proc-macro2", - "quote", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -2688,7 +2669,6 @@ dependencies = [ "comemo", "ecow", "heck", - "include_dir", "once_cell", "pulldown-cmark", "serde", diff --git a/Cargo.toml b/Cargo.toml index 99a1b432..3df6e051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/*", "tests", "tests/fuzz"] +members = ["crates/*", "docs", "tests", "tests/fuzz"] default-members = ["crates/typst-cli"] resolver = "2" @@ -58,7 +58,6 @@ icu_provider_blob = "1.4" icu_segmenter = { version = "1.4", features = ["serde"] } if_chain = "1" image = { version = "0.24", default-features = false, features = ["png", "jpeg", "gif"] } -include_dir = "0.7" indexmap = { version = "2", features = ["serde"] } kamadak-exif = "0.5" kurbo = "0.9" # in sync with usvg diff --git a/crates/typst-docs/Cargo.toml b/crates/typst-docs/Cargo.toml deleted file mode 100644 index af661bfb..00000000 --- a/crates/typst-docs/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "typst-docs" -version = { workspace = true } -rust-version = { workspace = true } -authors = { workspace = true } -edition = { workspace = true } -publish = false - -[lib] -doctest = false -bench = false - -[[bin]] -name = "typst-docs" -required-features = ["cli"] - -[features] -cli = ["clap", "typst-render", "serde_json"] - -[dependencies] -typst = { workspace = true } -typst-assets = { workspace = true, features = ["fonts"] } -typst-dev-assets = { workspace = true } -comemo = { workspace = true } -ecow = { workspace = true } -heck = { workspace = true } -include_dir = { workspace = true } -once_cell = { workspace = true } -pulldown-cmark = { workspace = true } -serde = { workspace = true } -serde_yaml = { workspace = true } -syntect = { workspace = true, features = ["html"] } -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/contribs.rs b/crates/typst-docs/src/contribs.rs deleted file mode 100644 index 58a730e2..00000000 --- a/crates/typst-docs/src/contribs.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::cmp::Reverse; -use std::collections::HashMap; -use std::fmt::Write; - -use serde::{Deserialize, Serialize}; - -use crate::{Html, Resolver}; - -/// Build HTML detailing the contributors between two tags. -pub fn contributors(resolver: &dyn Resolver, from: &str, to: &str) -> Option { - let staff = ["laurmaedje", "reknih"]; - - // Determine number of contributions per person. - let mut contributors = HashMap::::new(); - for commit in resolver.commits(from, to) { - contributors - .entry(commit.author.login.clone()) - .or_insert_with(|| Contributor { - login: commit.author.login, - avatar: commit.author.avatar_url, - contributions: 0, - }) - .contributions += 1; - } - - // Keep only non-staff people. - let mut contributors: Vec<_> = contributors - .into_values() - .filter(|c| !staff.contains(&c.login.as_str())) - .collect(); - - // Sort by highest number of commits. - contributors.sort_by_key(|c| (Reverse(c.contributions), c.login.clone())); - if contributors.is_empty() { - return None; - } - - let mut html = "Thanks to everyone who contributed to this release!".to_string(); - html += ""; - - Some(Html::new(html)) -} - -#[derive(Debug)] -struct Contributor { - login: String, - avatar: String, - contributions: usize, -} - -/// A commit on the `typst` repository. -#[derive(Debug, Serialize, Deserialize)] -pub struct Commit { - author: Author, -} - -/// A commit author. -#[derive(Debug, Serialize, Deserialize)] -pub struct Author { - login: String, - avatar_url: String, -} diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs deleted file mode 100644 index 6c47b8df..00000000 --- a/crates/typst-docs/src/html.rs +++ /dev/null @@ -1,469 +0,0 @@ -use std::fmt::{self, Debug, Formatter}; -use std::ops::Range; - -use comemo::Prehashed; -use ecow::EcoString; -use heck::{ToKebabCase, ToTitleCase}; -use pulldown_cmark as md; -use serde::{Deserialize, Serialize}; -use typed_arena::Arena; -use typst::diag::{FileResult, StrResult}; -use typst::eval::Tracer; -use typst::foundations::{Bytes, Datetime}; -use typst::layout::{Abs, Point, Size}; -use typst::syntax::{FileId, Source, VirtualPath}; -use typst::text::{Font, FontBook}; -use typst::{Library, World}; -use unscanny::Scanner; -use yaml_front_matter::YamlFrontMatter; - -use crate::{contributors, OutlineItem, Resolver, FONTS, LIBRARY}; - -/// HTML documentation. -#[derive(Serialize)] -#[serde(transparent)] -pub struct Html { - raw: String, - #[serde(skip)] - md: String, - #[serde(skip)] - description: Option, - #[serde(skip)] - outline: Vec, -} - -impl Html { - /// Create HTML from a raw string. - pub fn new(raw: String) -> Self { - Self { - md: String::new(), - raw, - description: None, - outline: vec![], - } - } - - /// Convert markdown to HTML. - #[track_caller] - pub fn markdown(resolver: &dyn Resolver, md: &str, nesting: Option) -> Self { - let mut text = md; - let mut description = None; - let document = YamlFrontMatter::parse::(md); - if let Ok(document) = &document { - text = &document.content; - description = Some(document.metadata.description.clone()) - } - - let options = md::Options::ENABLE_TABLES - | md::Options::ENABLE_FOOTNOTES - | md::Options::ENABLE_STRIKETHROUGH - | md::Options::ENABLE_HEADING_ATTRIBUTES; - - let ids = Arena::new(); - let mut handler = Handler::new(text, resolver, nesting, &ids); - let mut events = md::Parser::new_ext(text, options).peekable(); - let iter = std::iter::from_fn(|| loop { - let mut event = events.next()?; - handler.peeked = events.peek().and_then(|event| match event { - md::Event::Text(text) => Some(text.clone()), - _ => None, - }); - if handler.handle(&mut event) { - return Some(event); - } - }); - - let mut raw = String::new(); - md::html::push_html(&mut raw, iter); - raw.truncate(raw.trim_end().len()); - - Html { - md: text.into(), - raw, - description, - outline: handler.outline, - } - } - - /// The raw HTML. - pub fn as_str(&self) -> &str { - &self.raw - } - - /// The original Markdown, if any. - pub fn md(&self) -> &str { - &self.md - } - - /// The title of the HTML. - /// - /// Returns `None` if the HTML doesn't start with an `h1` tag. - pub fn title(&self) -> Option<&str> { - let mut s = Scanner::new(&self.raw); - s.eat_if("'); - s.eat_if('>'); - s.eat_until("") - }) - } - - /// The outline of the HTML. - pub fn outline(&self) -> Vec { - self.outline.clone() - } - - /// The description from the front matter. - pub fn description(&self) -> Option { - self.description.clone() - } -} - -impl Debug for Html { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "Html({:?})", self.title().unwrap_or("..")) - } -} - -/// Front matter metadata. -#[derive(Deserialize)] -struct Metadata { - description: EcoString, -} - -struct Handler<'a> { - text: &'a str, - resolver: &'a dyn Resolver, - peeked: Option>, - lang: Option, - code: EcoString, - outline: Vec, - nesting: Option, - ids: &'a Arena, -} - -impl<'a> Handler<'a> { - fn new( - text: &'a str, - resolver: &'a dyn Resolver, - nesting: Option, - ids: &'a Arena, - ) -> Self { - Self { - text, - resolver, - peeked: None, - lang: None, - code: EcoString::new(), - outline: vec![], - nesting, - ids, - } - } - - fn handle(&mut self, event: &mut md::Event<'a>) -> bool { - match event { - // Rewrite Markdown images. - md::Event::Start(md::Tag::Image(_, path, _)) => { - *path = self.handle_image(path).into(); - } - - // Rewrite HTML images. - md::Event::Html(html) if html.starts_with(" { - let range = html_attr_range(html, "src").unwrap(); - let path = &html[range.clone()]; - let mut buf = html.to_string(); - buf.replace_range(range, &self.handle_image(path)); - *html = buf.into(); - } - - // Register HTML headings for the outline. - md::Event::Start(md::Tag::Heading(level, id, _)) => { - self.handle_heading(id, level); - } - - // Also handle heading closings. - md::Event::End(md::Tag::Heading(level, _, _)) => { - nest_heading(level, self.nesting()); - } - - // Rewrite contributor sections. - md::Event::Html(html) if html.starts_with(" { - let from = html_attr(html, "from").unwrap(); - let to = html_attr(html, "to").unwrap(); - let Some(output) = contributors(self.resolver, from, to) else { - return false; - }; - *html = output.raw.into(); - } - - // Rewrite links. - md::Event::Start(md::Tag::Link(ty, dest, _)) => { - assert!( - matches!(ty, md::LinkType::Inline | md::LinkType::Reference), - "unsupported link type: {ty:?}", - ); - - *dest = match self.handle_link(dest) { - Ok(link) => link.into(), - Err(err) => panic!("invalid link: {dest} ({err})"), - }; - } - - // Inline raw. - md::Event::Code(code) => { - let mut chars = code.chars(); - let parser = match (chars.next(), chars.next_back()) { - (Some('['), Some(']')) => typst::syntax::parse, - (Some('{'), Some('}')) => typst::syntax::parse_code, - _ => return true, - }; - - let root = parser(&code[1..code.len() - 1]); - let html = typst::syntax::highlight_html(&root); - *event = md::Event::Html(html.into()); - } - - // Code blocks. - md::Event::Start(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(lang))) => { - self.lang = Some(lang.as_ref().into()); - self.code = EcoString::new(); - return false; - } - md::Event::End(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(_))) => { - let Some(lang) = self.lang.take() else { return false }; - let html = code_block(self.resolver, &lang, &self.code); - *event = md::Event::Html(html.raw.into()); - } - - // Example with preview. - md::Event::Text(text) => { - if self.lang.is_some() { - self.code.push_str(text); - return false; - } - } - - _ => {} - } - - true - } - - fn handle_image(&self, link: &str) -> String { - if let Some(data) = typst_dev_assets::get(link) { - self.resolver.image(link, data) - } else if let Some(url) = self.resolver.link(link) { - url - } else { - panic!("missing image: {link}") - } - } - - fn handle_heading( - &mut self, - id_slot: &mut Option<&'a str>, - level: &mut md::HeadingLevel, - ) { - nest_heading(level, self.nesting()); - if *level == md::HeadingLevel::H1 { - return; - } - - let default = self.peeked.as_ref().map(|text| text.to_kebab_case()); - let id: &'a str = match (&id_slot, default) { - (Some(id), default) => { - if Some(*id) == default.as_deref() { - eprintln!("heading id #{id} was specified unnecessarily"); - } - id - } - (None, Some(default)) => self.ids.alloc(default).as_str(), - (None, None) => panic!("missing heading id {}", self.text), - }; - - *id_slot = (!id.is_empty()).then_some(id); - - // Special case for things like "v0.3.0". - let name = if id.starts_with('v') && id.contains('.') { - id.into() - } else { - id.to_title_case().into() - }; - - let mut children = &mut self.outline; - let mut depth = *level as usize; - while depth > 2 { - if !children.is_empty() { - children = &mut children.last_mut().unwrap().children; - } - depth -= 1; - } - - children.push(OutlineItem { id: id.into(), name, children: vec![] }); - } - - fn handle_link(&self, link: &str) -> StrResult { - if let Some(link) = self.resolver.link(link) { - return Ok(link); - } - - crate::link::resolve(link, self.resolver.base()) - } - - fn nesting(&self) -> usize { - match self.nesting { - Some(nesting) => nesting, - None => panic!("headings are not allowed here:\n{}", self.text), - } - } -} - -/// Render a code block to HTML. -fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html { - let mut display = String::new(); - let mut compile = String::new(); - for line in text.lines() { - if let Some(suffix) = line.strip_prefix(">>>") { - compile.push_str(suffix); - compile.push('\n'); - } else if let Some(suffix) = line.strip_prefix("<<< ") { - display.push_str(suffix); - display.push('\n'); - } else { - display.push_str(line); - display.push('\n'); - compile.push_str(line); - compile.push('\n'); - } - } - - let mut parts = lang.split(':'); - let lang = parts.next().unwrap_or(lang); - - let mut zoom: Option<[Abs; 4]> = None; - let mut single = false; - if let Some(args) = parts.next() { - single = true; - if !args.contains("single") { - zoom = args - .split(',') - .take(4) - .map(|s| Abs::pt(s.parse().unwrap())) - .collect::>() - .try_into() - .ok(); - } - } - - if lang.is_empty() { - let mut buf = String::from("
");
-        md::escape::escape_html(&mut buf, &display).unwrap();
-        buf.push_str("
"); - return Html::new(buf); - } else if !matches!(lang, "example" | "typ" | "preview") { - let set = &*typst::text::RAW_SYNTAXES; - let buf = syntect::html::highlighted_html_for_string( - &display, - set, - set.find_syntax_by_token(lang) - .unwrap_or_else(|| panic!("unsupported highlighting language: {lang}")), - &typst::text::RAW_THEME, - ) - .expect("failed to highlight code"); - return Html::new(buf); - } - - let mut highlighted = None; - if matches!(lang, "example" | "typ") { - let root = typst::syntax::parse(&display); - let html = Html::new(typst::syntax::highlight_html(&root)); - if lang == "typ" { - return Html::new(format!("
{}
", html.as_str())); - } - highlighted = Some(html); - } - - let id = FileId::new(None, VirtualPath::new("main.typ")); - let source = Source::new(id, compile); - let world = DocWorld(source); - - let mut tracer = Tracer::new(); - let mut document = match typst::compile(&world, &mut tracer) { - Ok(doc) => doc, - Err(err) => { - let msg = &err[0].message; - panic!("while trying to compile:\n{text}:\n\nerror: {msg}"); - } - }; - - if let Some([x, y, w, h]) = zoom { - document.pages[0].frame.translate(Point::new(-x, -y)); - *document.pages[0].frame.size_mut() = Size::new(w, h); - } - - if single { - document.pages.truncate(1); - } - - let hash = typst::util::hash128(text); - resolver.example(hash, highlighted, &document) -} - -/// Extract an attribute value from an HTML element. -fn html_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> { - html.get(html_attr_range(html, attr)?) -} - -/// Extract the range of the attribute value of an HTML element. -fn html_attr_range(html: &str, attr: &str) -> Option> { - let needle = format!("{attr}=\""); - let offset = html.find(&needle)? + needle.len(); - let len = html[offset..].find('"')?; - Some(offset..offset + len) -} - -/// Increase the nesting level of a Markdown heading. -fn nest_heading(level: &mut md::HeadingLevel, nesting: usize) { - *level = ((*level as usize) + nesting) - .try_into() - .unwrap_or(md::HeadingLevel::H6); -} - -/// A world for example compilations. -struct DocWorld(Source); - -impl World for DocWorld { - fn library(&self) -> &Prehashed { - &LIBRARY - } - - fn book(&self) -> &Prehashed { - &FONTS.0 - } - - fn main(&self) -> Source { - self.0.clone() - } - - fn source(&self, _: FileId) -> FileResult { - Ok(self.0.clone()) - } - - fn file(&self, id: FileId) -> FileResult { - assert!(id.package().is_none()); - Ok(Bytes::from_static( - typst_dev_assets::get_by_name( - &id.vpath().as_rootless_path().to_string_lossy(), - ) - .unwrap_or_else(|| panic!("failed to load {:?}", id.vpath())), - )) - } - - fn font(&self, index: usize) -> Option { - Some(FONTS.1[index].clone()) - } - - fn today(&self, _: Option) -> Option { - Some(Datetime::from_ymd(1970, 1, 1).unwrap()) - } -} diff --git a/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs deleted file mode 100644 index 5439ad1b..00000000 --- a/crates/typst-docs/src/lib.rs +++ /dev/null @@ -1,840 +0,0 @@ -//! Documentation provider for Typst. - -mod contribs; -mod html; -mod link; -mod model; - -pub use self::contribs::*; -pub use self::html::*; -pub use self::model::*; - -use std::path::Path; - -use comemo::Prehashed; -use ecow::{eco_format, EcoString}; -use heck::ToTitleCase; -use include_dir::{include_dir, Dir}; -use once_cell::sync::Lazy; -use serde::de::DeserializeOwned; -use serde::Deserialize; -use serde_yaml as yaml; -use typst::diag::{bail, StrResult}; -use typst::foundations::Bytes; -use typst::foundations::{ - CastInfo, Category, Func, Module, ParamInfo, Repr, Scope, Smart, Type, Value, - FOUNDATIONS, -}; -use typst::introspection::INTROSPECTION; -use typst::layout::{Abs, Margin, PageElem, LAYOUT}; -use typst::loading::DATA_LOADING; -use typst::math::MATH; -use typst::model::Document; -use typst::model::MODEL; -use typst::symbols::SYMBOLS; -use typst::text::{Font, FontBook, TEXT}; -use typst::visualize::VISUALIZE; -use typst::Library; - -static DOCS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../docs"); - -static GROUPS: Lazy> = Lazy::new(|| { - let mut groups: Vec = yaml("reference/groups.yml"); - for group in &mut groups { - if group.filter.is_empty() { - group.filter = group - .module() - .scope() - .iter() - .filter(|(_, v)| matches!(v, Value::Func(_))) - .map(|(k, _)| k.clone()) - .collect(); - } - } - groups -}); - -static LIBRARY: Lazy> = Lazy::new(|| { - let mut lib = Library::default(); - lib.styles - .set(PageElem::set_width(Smart::Custom(Abs::pt(240.0).into()))); - lib.styles.set(PageElem::set_height(Smart::Auto)); - lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom( - Abs::pt(15.0).into(), - ))))); - Prehashed::new(lib) -}); - -static FONTS: Lazy<(Prehashed, Vec)> = Lazy::new(|| { - let fonts: Vec<_> = typst_assets::fonts() - .chain(typst_dev_assets::fonts()) - .flat_map(|data| Font::iter(Bytes::from_static(data))) - .collect(); - let book = FontBook::from_fonts(&fonts); - (Prehashed::new(book), fonts) -}); - -/// Build documentation pages. -pub fn provide(resolver: &dyn Resolver) -> Vec { - vec![ - markdown_page(resolver, resolver.base(), "overview.md") - .with_route(resolver.base()), - tutorial_pages(resolver), - reference_pages(resolver), - guide_pages(resolver), - packages_page(resolver), - markdown_page(resolver, resolver.base(), "changelog.md"), - markdown_page(resolver, resolver.base(), "roadmap.md"), - markdown_page(resolver, resolver.base(), "community.md"), - ] -} - -/// Resolve consumer dependencies. -pub trait Resolver { - /// Try to resolve a link that the system cannot resolve itself. - fn link(&self, link: &str) -> Option; - - /// Produce an URL for an image file. - fn image(&self, filename: &str, data: &[u8]) -> String; - - /// Produce HTML for an example. - fn example(&self, hash: u128, source: Option, document: &Document) -> Html; - - /// Determine the commits between two tags. - fn commits(&self, from: &str, to: &str) -> Vec; - - /// Get the base URL for the routes and links. This must end with a slash. - fn base(&self) -> &str; -} - -/// Create a page from a markdown file. -#[track_caller] -fn markdown_page( - resolver: &dyn Resolver, - parent: &str, - path: impl AsRef, -) -> PageModel { - assert!(parent.starts_with('/') && parent.ends_with('/')); - let md = DOCS_DIR.get_file(path).unwrap().contents_utf8().unwrap(); - let html = Html::markdown(resolver, md, Some(0)); - let title: EcoString = html.title().expect("chapter lacks a title").into(); - PageModel { - route: eco_format!("{parent}{}/", urlify(&title)), - title, - description: html.description().unwrap(), - part: None, - outline: html.outline(), - body: BodyModel::Html(html), - children: vec![], - } -} - -/// Build the tutorial. -fn tutorial_pages(resolver: &dyn Resolver) -> PageModel { - let mut page = markdown_page(resolver, resolver.base(), "tutorial/welcome.md"); - page.children = DOCS_DIR - .get_dir("tutorial") - .unwrap() - .files() - .filter(|file| file.path() != Path::new("tutorial/welcome.md")) - .map(|file| { - markdown_page(resolver, &format!("{}tutorial/", resolver.base()), file.path()) - }) - .collect(); - page -} - -/// Build the reference. -fn reference_pages(resolver: &dyn Resolver) -> PageModel { - let mut page = markdown_page(resolver, resolver.base(), "reference/welcome.md"); - page.children = vec![ - markdown_page( - resolver, - &format!("{}reference/", resolver.base()), - "reference/syntax.md", - ) - .with_part("Language"), - markdown_page( - resolver, - &format!("{}reference/", resolver.base()), - "reference/styling.md", - ), - markdown_page( - resolver, - &format!("{}reference/", resolver.base()), - "reference/scripting.md", - ), - markdown_page( - resolver, - &format!("{}reference/", resolver.base()), - "reference/context.md", - ), - category_page(resolver, FOUNDATIONS).with_part("Library"), - category_page(resolver, MODEL), - category_page(resolver, TEXT), - category_page(resolver, MATH), - category_page(resolver, SYMBOLS), - category_page(resolver, LAYOUT), - category_page(resolver, VISUALIZE), - category_page(resolver, INTROSPECTION), - category_page(resolver, DATA_LOADING), - ]; - page -} - -/// Build the guides section. -fn guide_pages(resolver: &dyn Resolver) -> PageModel { - let mut page = markdown_page(resolver, resolver.base(), "guides/welcome.md"); - page.children = vec![ - markdown_page( - resolver, - &format!("{}guides/", resolver.base()), - "guides/guide-for-latex-users.md", - ), - markdown_page( - resolver, - &format!("{}guides/", resolver.base()), - "guides/page-setup.md", - ), - ]; - page -} - -/// Build the packages section. -fn packages_page(resolver: &dyn Resolver) -> PageModel { - let md = DOCS_DIR - .get_file("reference/packages.md") - .unwrap() - .contents_utf8() - .unwrap(); - PageModel { - route: eco_format!("{}packages/", resolver.base()), - title: "Packages".into(), - description: "Packages for Typst.".into(), - part: None, - outline: vec![], - body: BodyModel::Packages(Html::markdown(resolver, md, Some(1))), - children: vec![], - } -} - -/// Create a page for a category. -#[track_caller] -fn category_page(resolver: &dyn Resolver, category: Category) -> PageModel { - let route = eco_format!("{}reference/{}/", resolver.base(), category.name()); - let mut children = vec![]; - let mut items = vec![]; - let mut shorthands = None; - let mut markup = vec![]; - let mut math = vec![]; - - let (module, path): (&Module, &[&str]) = if category == MATH { - (&LIBRARY.math, &["math"]) - } else { - (&LIBRARY.global, &[]) - }; - - // Add groups. - for group in GROUPS.iter().filter(|g| g.category == category.name()).cloned() { - if matches!(group.name.as_str(), "sym" | "emoji") { - let subpage = symbols_page(resolver, &route, &group); - let BodyModel::Symbols(model) = &subpage.body else { continue }; - let list = &model.list; - markup.extend( - list.iter() - .filter(|symbol| symbol.markup_shorthand.is_some()) - .cloned(), - ); - math.extend( - list.iter().filter(|symbol| symbol.math_shorthand.is_some()).cloned(), - ); - - items.push(CategoryItem { - name: group.name.clone(), - route: subpage.route.clone(), - oneliner: oneliner(category.docs()).into(), - code: true, - }); - children.push(subpage); - continue; - } - - let (child, item) = group_page(resolver, &route, &group); - children.push(child); - items.push(item); - } - - // Add symbol pages. These are ordered manually. - if category == SYMBOLS { - shorthands = Some(ShorthandsModel { markup, math }); - } - - // Add functions. - let scope = module.scope(); - for (name, value) in scope.iter() { - if scope.get_category(name) != Some(category) { - continue; - } - - if category == MATH { - // Skip grouped functions. - if GROUPS.iter().flat_map(|group| &group.filter).any(|f| f == name) { - continue; - } - - // Already documented in the text category. - if name == "text" { - continue; - } - } - - match value { - Value::Func(func) => { - let name = func.name().unwrap(); - - let subpage = func_page(resolver, &route, func, path); - items.push(CategoryItem { - name: name.into(), - route: subpage.route.clone(), - oneliner: oneliner(func.docs().unwrap_or_default()).into(), - code: true, - }); - children.push(subpage); - } - Value::Type(ty) => { - let subpage = type_page(resolver, &route, ty); - items.push(CategoryItem { - name: ty.short_name().into(), - route: subpage.route.clone(), - oneliner: oneliner(ty.docs()).into(), - code: true, - }); - children.push(subpage); - } - _ => {} - } - } - - if category != SYMBOLS { - children.sort_by_cached_key(|child| child.title.clone()); - items.sort_by_cached_key(|item| item.name.clone()); - } - - let name = category.title(); - let details = Html::markdown(resolver, category.docs(), Some(1)); - let mut outline = vec![OutlineItem::from_name("Summary")]; - outline.extend(details.outline()); - outline.push(OutlineItem::from_name("Definitions")); - if shorthands.is_some() { - outline.push(OutlineItem::from_name("Shorthands")); - } - - PageModel { - route, - title: name.into(), - description: eco_format!( - "Documentation for functions related to {name} in Typst." - ), - part: None, - outline, - body: BodyModel::Category(CategoryModel { - name: category.name(), - title: category.title(), - details, - items, - shorthands, - }), - children, - } -} - -/// Create a page for a function. -fn func_page( - resolver: &dyn Resolver, - parent: &str, - func: &Func, - path: &[&str], -) -> PageModel { - let model = func_model(resolver, func, path, false); - let name = func.name().unwrap(); - PageModel { - route: eco_format!("{parent}{}/", urlify(name)), - title: func.title().unwrap().into(), - description: eco_format!("Documentation for the `{name}` function."), - part: None, - outline: func_outline(&model, ""), - body: BodyModel::Func(model), - children: vec![], - } -} - -/// Produce a function's model. -fn func_model( - resolver: &dyn Resolver, - func: &Func, - path: &[&str], - nested: bool, -) -> FuncModel { - let name = func.name().unwrap(); - let scope = func.scope().unwrap(); - let docs = func.docs().unwrap(); - - let mut self_ = false; - let mut params = func.params().unwrap(); - if params.first().map_or(false, |first| first.name == "self") { - self_ = true; - params = ¶ms[1..]; - } - - let mut returns = vec![]; - casts(resolver, &mut returns, &mut vec![], func.returns().unwrap()); - returns.sort_by_key(|ty| type_index(ty)); - if returns == ["none"] { - returns.clear(); - } - - let nesting = if nested { None } else { Some(1) }; - let (details, example) = - if nested { split_details_and_example(docs) } else { (docs, None) }; - - FuncModel { - path: path.iter().copied().map(Into::into).collect(), - name: name.into(), - title: func.title().unwrap(), - keywords: func.keywords(), - oneliner: oneliner(details), - element: func.element().is_some(), - contextual: func.contextual().unwrap_or(false), - details: Html::markdown(resolver, details, nesting), - example: example.map(|md| Html::markdown(resolver, md, None)), - self_, - params: params.iter().map(|param| param_model(resolver, param)).collect(), - returns, - scope: scope_models(resolver, name, scope), - } -} - -/// Produce a parameter's model. -fn param_model(resolver: &dyn Resolver, info: &ParamInfo) -> ParamModel { - let (details, example) = split_details_and_example(info.docs); - - let mut types = vec![]; - let mut strings = vec![]; - casts(resolver, &mut types, &mut strings, &info.input); - if !strings.is_empty() && !types.contains(&"str") { - types.push("str"); - } - types.sort_by_key(|ty| type_index(ty)); - - ParamModel { - name: info.name, - details: Html::markdown(resolver, details, None), - example: example.map(|md| Html::markdown(resolver, md, None)), - types, - strings, - default: info.default.map(|default| { - let node = typst::syntax::parse_code(&default().repr()); - Html::new(typst::syntax::highlight_html(&node)) - }), - positional: info.positional, - named: info.named, - required: info.required, - variadic: info.variadic, - settable: info.settable, - } -} - -/// Split up documentation into details and an example. -fn split_details_and_example(docs: &str) -> (&str, Option<&str>) { - let mut details = docs; - let mut example = None; - if let Some(mut i) = docs.find("```") { - while docs[..i].ends_with('`') { - i -= 1; - } - details = &docs[..i]; - example = Some(&docs[i..]); - } - (details, example) -} - -/// Process cast information into types and strings. -fn casts( - resolver: &dyn Resolver, - types: &mut Vec<&'static str>, - strings: &mut Vec, - info: &CastInfo, -) { - match info { - CastInfo::Any => types.push("any"), - CastInfo::Value(Value::Str(string), docs) => strings.push(StrParam { - string: string.clone().into(), - details: Html::markdown(resolver, docs, None), - }), - CastInfo::Value(..) => {} - CastInfo::Type(ty) => types.push(ty.short_name()), - CastInfo::Union(options) => { - for option in options { - casts(resolver, types, strings, option); - } - } - } -} - -/// Produce models for a function's scope. -fn scope_models(resolver: &dyn Resolver, name: &str, scope: &Scope) -> Vec { - scope - .iter() - .filter_map(|(_, value)| { - let Value::Func(func) = value else { return None }; - Some(func_model(resolver, func, &[name], true)) - }) - .collect() -} - -/// Produce an outline for a function page. -fn func_outline(model: &FuncModel, id_base: &str) -> Vec { - let mut outline = vec![]; - - if id_base.is_empty() { - outline.push(OutlineItem::from_name("Summary")); - outline.extend(model.details.outline()); - - if !model.params.is_empty() { - outline.push(OutlineItem { - id: "parameters".into(), - name: "Parameters".into(), - children: model - .params - .iter() - .map(|param| OutlineItem { - id: eco_format!("parameters-{}", urlify(param.name)), - name: param.name.into(), - children: vec![], - }) - .collect(), - }); - } - - outline.extend(scope_outline(&model.scope)); - } else { - outline.extend(model.params.iter().map(|param| OutlineItem { - id: eco_format!("{id_base}-{}", urlify(param.name)), - name: param.name.into(), - children: vec![], - })); - } - - outline -} - -/// Produce an outline for a function scope. -fn scope_outline(scope: &[FuncModel]) -> Option { - if scope.is_empty() { - return None; - } - - Some(OutlineItem { - id: "definitions".into(), - name: "Definitions".into(), - children: scope - .iter() - .map(|func| { - let id = urlify(&eco_format!("definitions-{}", func.name)); - let children = func_outline(func, &id); - OutlineItem { id, name: func.title.into(), children } - }) - .collect(), - }) -} - -/// Create a page for a group of functions. -fn group_page( - resolver: &dyn Resolver, - parent: &str, - group: &GroupData, -) -> (PageModel, CategoryItem) { - let mut functions = vec![]; - let mut outline = vec![OutlineItem::from_name("Summary")]; - - let path: Vec<_> = group.path.iter().map(|s| s.as_str()).collect(); - let details = Html::markdown(resolver, &group.details, Some(1)); - outline.extend(details.outline()); - - let mut outline_items = vec![]; - for name in &group.filter { - let value = group.module().scope().get(name).unwrap(); - let Value::Func(func) = value else { panic!("not a function") }; - let func = func_model(resolver, func, &path, true); - let id_base = urlify(&eco_format!("functions-{}", func.name)); - let children = func_outline(&func, &id_base); - outline_items.push(OutlineItem { - id: id_base, - name: func.title.into(), - children, - }); - functions.push(func); - } - - outline.push(OutlineItem { - id: "functions".into(), - name: "Functions".into(), - children: outline_items, - }); - - let model = PageModel { - route: eco_format!("{parent}{}", group.name), - title: group.title.clone(), - description: eco_format!("Documentation for the {} functions.", group.name), - part: None, - outline, - body: BodyModel::Group(GroupModel { - name: group.name.clone(), - title: group.title.clone(), - details, - functions, - }), - children: vec![], - }; - - let item = CategoryItem { - name: group.name.clone(), - route: model.route.clone(), - oneliner: oneliner(&group.details).into(), - code: false, - }; - - (model, item) -} - -/// Create a page for a type. -fn type_page(resolver: &dyn Resolver, parent: &str, ty: &Type) -> PageModel { - let model = type_model(resolver, ty); - PageModel { - route: eco_format!("{parent}{}/", urlify(ty.short_name())), - title: ty.title().into(), - description: eco_format!("Documentation for the {} type.", ty.title()), - part: None, - outline: type_outline(&model), - body: BodyModel::Type(model), - children: vec![], - } -} - -/// Produce a type's model. -fn type_model(resolver: &dyn Resolver, ty: &Type) -> TypeModel { - TypeModel { - name: ty.short_name(), - title: ty.title(), - keywords: ty.keywords(), - oneliner: oneliner(ty.docs()), - details: Html::markdown(resolver, ty.docs(), Some(1)), - constructor: ty - .constructor() - .ok() - .map(|func| func_model(resolver, &func, &[], true)), - scope: scope_models(resolver, ty.short_name(), ty.scope()), - } -} - -/// Produce an outline for a type page. -fn type_outline(model: &TypeModel) -> Vec { - let mut outline = vec![OutlineItem::from_name("Summary")]; - outline.extend(model.details.outline()); - - if let Some(func) = &model.constructor { - outline.push(OutlineItem { - id: "constructor".into(), - name: "Constructor".into(), - children: func_outline(func, "constructor"), - }); - } - - outline.extend(scope_outline(&model.scope)); - outline -} - -/// Create a page for symbols. -fn symbols_page(resolver: &dyn Resolver, parent: &str, group: &GroupData) -> PageModel { - let model = symbols_model(resolver, group); - PageModel { - route: eco_format!("{parent}{}/", group.name), - title: group.title.clone(), - description: eco_format!("Documentation for the `{}` module.", group.name), - part: None, - outline: vec![], - body: BodyModel::Symbols(model), - children: vec![], - } -} - -/// Produce a symbol list's model. -fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { - let mut list = vec![]; - for (name, value) in group.module().scope().iter() { - let Value::Symbol(symbol) = value else { continue }; - let complete = |variant: &str| { - if variant.is_empty() { - name.clone() - } else { - eco_format!("{}.{}", name, variant) - } - }; - - for (variant, c) in symbol.variants() { - let shorthand = |list: &[(&'static str, char)]| { - list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) - }; - - list.push(SymbolModel { - name: complete(variant), - markup_shorthand: shorthand(typst::syntax::ast::Shorthand::MARKUP_LIST), - math_shorthand: shorthand(typst::syntax::ast::Shorthand::MATH_LIST), - codepoint: c as u32, - accent: typst::symbols::Symbol::combining_accent(c).is_some(), - unicode_name: unicode_names2::name(c) - .map(|s| s.to_string().to_title_case().into()), - alternates: symbol - .variants() - .filter(|(other, _)| other != &variant) - .map(|(other, _)| complete(other)) - .collect(), - }); - } - } - - SymbolsModel { - name: group.name.clone(), - title: group.title.clone(), - details: Html::markdown(resolver, &group.details, Some(1)), - list, - } -} - -/// Extract a module from another module. -#[track_caller] -fn get_module<'a>(parent: &'a Module, name: &str) -> StrResult<&'a Module> { - match parent.scope().get(name) { - Some(Value::Module(module)) => Ok(module), - _ => bail!("module doesn't contain module `{name}`"), - } -} - -/// Load YAML from a path. -#[track_caller] -fn yaml(path: &str) -> T { - let file = DOCS_DIR.get_file(path).unwrap(); - yaml::from_slice(file.contents()).unwrap() -} - -/// Turn a title into an URL fragment. -pub fn urlify(title: &str) -> EcoString { - title - .chars() - .map(|c| c.to_ascii_lowercase()) - .map(|c| match c { - 'a'..='z' | '0'..='9' => c, - _ => '-', - }) - .collect() -} - -/// Extract the first line of documentation. -fn oneliner(docs: &str) -> &str { - docs.lines().next().unwrap_or_default() -} - -/// The order of types in the documentation. -fn type_index(ty: &str) -> usize { - TYPE_ORDER.iter().position(|&v| v == ty).unwrap_or(usize::MAX) -} - -const TYPE_ORDER: &[&str] = &[ - "any", - "none", - "auto", - "bool", - "int", - "float", - "length", - "angle", - "ratio", - "relative", - "fraction", - "color", - "gradient", - "datetime", - "duration", - "str", - "bytes", - "regex", - "label", - "content", - "array", - "dict", - "func", - "args", - "selector", - "location", - "direction", - "alignment", - "alignment2d", - "stroke", -]; - -/// Data about a collection of functions. -#[derive(Debug, Clone, Deserialize)] -struct GroupData { - name: EcoString, - title: EcoString, - category: EcoString, - #[serde(default)] - path: Vec, - #[serde(default)] - filter: Vec, - details: EcoString, -} - -impl GroupData { - fn module(&self) -> &'static Module { - let mut focus = &LIBRARY.global; - for path in &self.path { - focus = get_module(focus, path).unwrap(); - } - focus - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_docs() { - provide(&TestResolver); - } - - struct TestResolver; - - impl Resolver for TestResolver { - fn link(&self, _: &str) -> Option { - None - } - - fn example(&self, _: u128, _: Option, _: &Document) -> Html { - Html::new(String::new()) - } - - fn image(&self, _: &str, _: &[u8]) -> String { - String::new() - } - - fn commits(&self, _: &str, _: &str) -> Vec { - vec![] - } - - fn base(&self) -> &str { - "/" - } - } -} diff --git a/crates/typst-docs/src/link.rs b/crates/typst-docs/src/link.rs deleted file mode 100644 index f4d803c3..00000000 --- a/crates/typst-docs/src/link.rs +++ /dev/null @@ -1,108 +0,0 @@ -use typst::diag::{bail, StrResult}; -use typst::foundations::Func; - -use crate::{get_module, GROUPS, LIBRARY}; - -/// Resolve an intra-doc link. -pub fn resolve(link: &str, base: &str) -> StrResult { - if link.starts_with('#') || link.starts_with("http") { - return Ok(link.to_string()); - } - - let (head, tail) = split_link(link)?; - let mut route = match resolve_known(head, base) { - Some(route) => route, - None => resolve_definition(head, base)?, - }; - - if !tail.is_empty() { - route.push('/'); - route.push_str(tail); - } - - if !route.contains('#') && !route.ends_with('/') { - route.push('/'); - } - - Ok(route) -} - -/// Split a link at the first slash. -fn split_link(link: &str) -> StrResult<(&str, &str)> { - let first = link.split('/').next().unwrap_or(link); - let rest = link[first.len()..].trim_start_matches('/'); - Ok((first, rest)) -} - -/// Resolve a `$` link head to a known destination. -fn resolve_known(head: &str, base: &str) -> Option { - Some(match head { - "$tutorial" => format!("{base}tutorial"), - "$reference" => format!("{base}reference"), - "$category" => format!("{base}reference"), - "$syntax" => format!("{base}reference/syntax"), - "$styling" => format!("{base}reference/styling"), - "$scripting" => format!("{base}reference/scripting"), - "$context" => format!("{base}reference/context"), - "$guides" => format!("{base}guides"), - "$packages" => format!("{base}packages"), - "$changelog" => format!("{base}changelog"), - "$community" => format!("{base}community"), - _ => return None, - }) -} - -/// Resolve a `$` link to a global definition. -fn resolve_definition(head: &str, base: &str) -> StrResult { - let mut parts = head.trim_start_matches('$').split('.').peekable(); - let mut focus = &LIBRARY.global; - let mut category = None; - - while let Some(name) = parts.peek() { - if category.is_none() { - category = focus.scope().get_category(name); - } - let Ok(module) = get_module(focus, name) else { break }; - focus = module; - parts.next(); - } - - let Some(category) = category else { bail!("{head} has no category") }; - - let name = parts.next().ok_or("link is missing first part")?; - let value = focus.field(name)?; - - // Handle grouped functions. - if let Some(group) = GROUPS.iter().find(|group| { - group.category == category.name() && group.filter.iter().any(|func| func == name) - }) { - let mut route = format!( - "{}reference/{}/{}/#functions-{}", - base, group.category, group.name, name - ); - if let Some(param) = parts.next() { - route.push('-'); - route.push_str(param); - } - return Ok(route); - } - - let mut route = format!("{}reference/{}/{name}/", base, category.name()); - if let Some(next) = parts.next() { - if value.field(next).is_ok() { - route.push_str("#definitions-"); - route.push_str(next); - } else if value - .clone() - .cast::() - .map_or(false, |func| func.param(next).is_some()) - { - route.push_str("#parameters-"); - route.push_str(next); - } else { - bail!("field {next} not found"); - } - } - - Ok(route) -} diff --git a/crates/typst-docs/src/main.rs b/crates/typst-docs/src/main.rs deleted file mode 100644 index f4414b10..00000000 --- a/crates/typst-docs/src/main.rs +++ /dev/null @@ -1,148 +0,0 @@ -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(()) -} diff --git a/crates/typst-docs/src/model.rs b/crates/typst-docs/src/model.rs deleted file mode 100644 index 1564ef2f..00000000 --- a/crates/typst-docs/src/model.rs +++ /dev/null @@ -1,173 +0,0 @@ -use ecow::EcoString; -use heck::ToKebabCase; -use serde::Serialize; - -use crate::html::Html; - -/// Details about a documentation page and its children. -#[derive(Debug, Serialize)] -pub struct PageModel { - pub route: EcoString, - pub title: EcoString, - pub description: EcoString, - pub part: Option<&'static str>, - pub outline: Vec, - pub body: BodyModel, - pub children: Vec, -} - -impl PageModel { - pub fn with_route(self, route: &str) -> Self { - Self { route: route.into(), ..self } - } - - pub fn with_part(self, part: &'static str) -> Self { - Self { part: Some(part), ..self } - } -} - -/// An element in the "On This Page" outline. -#[derive(Debug, Clone, Serialize)] -pub struct OutlineItem { - pub id: EcoString, - pub name: EcoString, - pub children: Vec, -} - -impl OutlineItem { - /// Create an outline item from a name with auto-generated id. - pub fn from_name(name: &str) -> Self { - Self { - id: name.to_kebab_case().into(), - name: name.into(), - children: vec![], - } - } -} - -/// Details about the body of a documentation page. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -#[serde(tag = "kind", content = "content")] -pub enum BodyModel { - Html(Html), - Category(CategoryModel), - Func(FuncModel), - Group(GroupModel), - Type(TypeModel), - Symbols(SymbolsModel), - Packages(Html), -} - -/// Details about a category. -#[derive(Debug, Serialize)] -pub struct CategoryModel { - pub name: &'static str, - pub title: &'static str, - pub details: Html, - pub items: Vec, - pub shorthands: Option, -} - -/// Details about a category item. -#[derive(Debug, Serialize)] -pub struct CategoryItem { - pub name: EcoString, - pub route: EcoString, - pub oneliner: EcoString, - pub code: bool, -} - -/// Details about a function. -#[derive(Debug, Serialize)] -pub struct FuncModel { - pub path: Vec, - pub name: EcoString, - pub title: &'static str, - pub keywords: &'static [&'static str], - pub oneliner: &'static str, - pub element: bool, - pub contextual: bool, - pub details: Html, - /// This example is only for nested function models. Others can have - /// their example directly in their details. - pub example: Option, - #[serde(rename = "self")] - pub self_: bool, - pub params: Vec, - pub returns: Vec<&'static str>, - pub scope: Vec, -} - -/// Details about a function parameter. -#[derive(Debug, Serialize)] -pub struct ParamModel { - pub name: &'static str, - pub details: Html, - pub example: Option, - pub types: Vec<&'static str>, - pub strings: Vec, - pub default: Option, - pub positional: bool, - pub named: bool, - pub required: bool, - pub variadic: bool, - pub settable: bool, -} - -/// A specific string that can be passed as an argument. -#[derive(Debug, Serialize)] -pub struct StrParam { - pub string: EcoString, - pub details: Html, -} - -/// Details about a group of functions. -#[derive(Debug, Serialize)] -pub struct GroupModel { - pub name: EcoString, - pub title: EcoString, - pub details: Html, - pub functions: Vec, -} - -/// Details about a type. -#[derive(Debug, Serialize)] -pub struct TypeModel { - pub name: &'static str, - pub title: &'static str, - pub keywords: &'static [&'static str], - pub oneliner: &'static str, - pub details: Html, - pub constructor: Option, - pub scope: Vec, -} - -/// A collection of symbols. -#[derive(Debug, Serialize)] -pub struct SymbolsModel { - pub name: EcoString, - pub title: EcoString, - pub details: Html, - pub list: Vec, -} - -/// Details about a symbol. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SymbolModel { - pub name: EcoString, - pub codepoint: u32, - pub accent: bool, - pub unicode_name: Option, - pub alternates: Vec, - pub markup_shorthand: Option<&'static str>, - pub math_shorthand: Option<&'static str>, -} - -/// Shorthands listed on a category page. -#[derive(Debug, Serialize)] -pub struct ShorthandsModel { - pub markup: Vec, - pub math: Vec, -} diff --git a/docs/Cargo.toml b/docs/Cargo.toml new file mode 100644 index 00000000..0342d612 --- /dev/null +++ b/docs/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "typst-docs" +version = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +publish = false + +[lib] +doctest = false +bench = false + +[[bin]] +name = "typst-docs" +required-features = ["cli"] + +[features] +cli = ["clap", "typst-render", "serde_json"] + +[dependencies] +typst = { workspace = true } +typst-assets = { workspace = true, features = ["fonts"] } +typst-dev-assets = { workspace = true } +comemo = { workspace = true } +ecow = { workspace = true } +heck = { workspace = true } +once_cell = { workspace = true } +pulldown-cmark = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +syntect = { workspace = true, features = ["html"] } +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/docs/src/contribs.rs b/docs/src/contribs.rs new file mode 100644 index 00000000..58a730e2 --- /dev/null +++ b/docs/src/contribs.rs @@ -0,0 +1,86 @@ +use std::cmp::Reverse; +use std::collections::HashMap; +use std::fmt::Write; + +use serde::{Deserialize, Serialize}; + +use crate::{Html, Resolver}; + +/// Build HTML detailing the contributors between two tags. +pub fn contributors(resolver: &dyn Resolver, from: &str, to: &str) -> Option { + let staff = ["laurmaedje", "reknih"]; + + // Determine number of contributions per person. + let mut contributors = HashMap::::new(); + for commit in resolver.commits(from, to) { + contributors + .entry(commit.author.login.clone()) + .or_insert_with(|| Contributor { + login: commit.author.login, + avatar: commit.author.avatar_url, + contributions: 0, + }) + .contributions += 1; + } + + // Keep only non-staff people. + let mut contributors: Vec<_> = contributors + .into_values() + .filter(|c| !staff.contains(&c.login.as_str())) + .collect(); + + // Sort by highest number of commits. + contributors.sort_by_key(|c| (Reverse(c.contributions), c.login.clone())); + if contributors.is_empty() { + return None; + } + + let mut html = "Thanks to everyone who contributed to this release!".to_string(); + html += "
    "; + + for Contributor { login, avatar, contributions } in contributors { + let login = login.replace('\"', """).replace('&', "&"); + let avatar = avatar.replace("?v=", "?s=64&v="); + let s = if contributions > 1 { "s" } else { "" }; + write!( + html, + r#"
  • + + GitHub avatar of {login} + +
  • "# + ) + .unwrap(); + } + + html += "
"; + + Some(Html::new(html)) +} + +#[derive(Debug)] +struct Contributor { + login: String, + avatar: String, + contributions: usize, +} + +/// A commit on the `typst` repository. +#[derive(Debug, Serialize, Deserialize)] +pub struct Commit { + author: Author, +} + +/// A commit author. +#[derive(Debug, Serialize, Deserialize)] +pub struct Author { + login: String, + avatar_url: String, +} diff --git a/docs/src/html.rs b/docs/src/html.rs new file mode 100644 index 00000000..912ad367 --- /dev/null +++ b/docs/src/html.rs @@ -0,0 +1,469 @@ +use std::fmt::{self, Debug, Formatter}; +use std::ops::Range; + +use comemo::Prehashed; +use ecow::EcoString; +use heck::{ToKebabCase, ToTitleCase}; +use pulldown_cmark as md; +use serde::{Deserialize, Serialize}; +use typed_arena::Arena; +use typst::diag::{FileResult, StrResult}; +use typst::eval::Tracer; +use typst::foundations::{Bytes, Datetime}; +use typst::layout::{Abs, Point, Size}; +use typst::syntax::{FileId, Source, VirtualPath}; +use typst::text::{Font, FontBook}; +use typst::{Library, World}; +use unscanny::Scanner; +use yaml_front_matter::YamlFrontMatter; + +use crate::{contributors, OutlineItem, Resolver, FONTS, LIBRARY}; + +/// HTML documentation. +#[derive(Serialize)] +#[serde(transparent)] +pub struct Html { + raw: String, + #[serde(skip)] + md: String, + #[serde(skip)] + description: Option, + #[serde(skip)] + outline: Vec, +} + +impl Html { + /// Create HTML from a raw string. + pub fn new(raw: String) -> Self { + Self { + md: String::new(), + raw, + description: None, + outline: vec![], + } + } + + /// Convert markdown to HTML. + #[track_caller] + pub fn markdown(resolver: &dyn Resolver, md: &str, nesting: Option) -> Self { + let mut text = md; + let mut description = None; + let document = YamlFrontMatter::parse::(md); + if let Ok(document) = &document { + text = &document.content; + description = Some(document.metadata.description.clone()) + } + + let options = md::Options::ENABLE_TABLES + | md::Options::ENABLE_FOOTNOTES + | md::Options::ENABLE_STRIKETHROUGH + | md::Options::ENABLE_HEADING_ATTRIBUTES; + + let ids = Arena::new(); + let mut handler = Handler::new(text, resolver, nesting, &ids); + let mut events = md::Parser::new_ext(text, options).peekable(); + let iter = std::iter::from_fn(|| loop { + let mut event = events.next()?; + handler.peeked = events.peek().and_then(|event| match event { + md::Event::Text(text) => Some(text.clone()), + _ => None, + }); + if handler.handle(&mut event) { + return Some(event); + } + }); + + let mut raw = String::new(); + md::html::push_html(&mut raw, iter); + raw.truncate(raw.trim_end().len()); + + Html { + md: text.into(), + raw, + description, + outline: handler.outline, + } + } + + /// The raw HTML. + pub fn as_str(&self) -> &str { + &self.raw + } + + /// The original Markdown, if any. + pub fn md(&self) -> &str { + &self.md + } + + /// The title of the HTML. + /// + /// Returns `None` if the HTML doesn't start with an `h1` tag. + pub fn title(&self) -> Option<&str> { + let mut s = Scanner::new(&self.raw); + s.eat_if("'); + s.eat_if('>'); + s.eat_until("") + }) + } + + /// The outline of the HTML. + pub fn outline(&self) -> Vec { + self.outline.clone() + } + + /// The description from the front matter. + pub fn description(&self) -> Option { + self.description.clone() + } +} + +impl Debug for Html { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "Html({:?})", self.title().unwrap_or("..")) + } +} + +/// Front matter metadata. +#[derive(Deserialize)] +struct Metadata { + description: EcoString, +} + +struct Handler<'a> { + text: &'a str, + resolver: &'a dyn Resolver, + peeked: Option>, + lang: Option, + code: EcoString, + outline: Vec, + nesting: Option, + ids: &'a Arena, +} + +impl<'a> Handler<'a> { + fn new( + text: &'a str, + resolver: &'a dyn Resolver, + nesting: Option, + ids: &'a Arena, + ) -> Self { + Self { + text, + resolver, + peeked: None, + lang: None, + code: EcoString::new(), + outline: vec![], + nesting, + ids, + } + } + + fn handle(&mut self, event: &mut md::Event<'a>) -> bool { + match event { + // Rewrite Markdown images. + md::Event::Start(md::Tag::Image(_, path, _)) => { + *path = self.handle_image(path).into(); + } + + // Rewrite HTML images. + md::Event::Html(html) if html.starts_with(" { + let range = html_attr_range(html, "src").unwrap(); + let path = &html[range.clone()]; + let mut buf = html.to_string(); + buf.replace_range(range, &self.handle_image(path)); + *html = buf.into(); + } + + // Register HTML headings for the outline. + md::Event::Start(md::Tag::Heading(level, id, _)) => { + self.handle_heading(id, level); + } + + // Also handle heading closings. + md::Event::End(md::Tag::Heading(level, _, _)) => { + nest_heading(level, self.nesting()); + } + + // Rewrite contributor sections. + md::Event::Html(html) if html.starts_with(" { + let from = html_attr(html, "from").unwrap(); + let to = html_attr(html, "to").unwrap(); + let Some(output) = contributors(self.resolver, from, to) else { + return false; + }; + *html = output.raw.into(); + } + + // Rewrite links. + md::Event::Start(md::Tag::Link(ty, dest, _)) => { + assert!( + matches!(ty, md::LinkType::Inline | md::LinkType::Reference), + "unsupported link type: {ty:?}", + ); + + *dest = match self.handle_link(dest) { + Ok(link) => link.into(), + Err(err) => panic!("invalid link: {dest} ({err})"), + }; + } + + // Inline raw. + md::Event::Code(code) => { + let mut chars = code.chars(); + let parser = match (chars.next(), chars.next_back()) { + (Some('['), Some(']')) => typst::syntax::parse, + (Some('{'), Some('}')) => typst::syntax::parse_code, + _ => return true, + }; + + let root = parser(&code[1..code.len() - 1]); + let html = typst::syntax::highlight_html(&root); + *event = md::Event::Html(html.into()); + } + + // Code blocks. + md::Event::Start(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(lang))) => { + self.lang = Some(lang.as_ref().into()); + self.code = EcoString::new(); + return false; + } + md::Event::End(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(_))) => { + let Some(lang) = self.lang.take() else { return false }; + let html = code_block(self.resolver, &lang, &self.code); + *event = md::Event::Html(html.raw.into()); + } + + // Example with preview. + md::Event::Text(text) => { + if self.lang.is_some() { + self.code.push_str(text); + return false; + } + } + + _ => {} + } + + true + } + + fn handle_image(&self, link: &str) -> String { + if let Some(data) = typst_dev_assets::get_by_name(link) { + self.resolver.image(link, data) + } else if let Some(url) = self.resolver.link(link) { + url + } else { + panic!("missing image: {link}") + } + } + + fn handle_heading( + &mut self, + id_slot: &mut Option<&'a str>, + level: &mut md::HeadingLevel, + ) { + nest_heading(level, self.nesting()); + if *level == md::HeadingLevel::H1 { + return; + } + + let default = self.peeked.as_ref().map(|text| text.to_kebab_case()); + let id: &'a str = match (&id_slot, default) { + (Some(id), default) => { + if Some(*id) == default.as_deref() { + eprintln!("heading id #{id} was specified unnecessarily"); + } + id + } + (None, Some(default)) => self.ids.alloc(default).as_str(), + (None, None) => panic!("missing heading id {}", self.text), + }; + + *id_slot = (!id.is_empty()).then_some(id); + + // Special case for things like "v0.3.0". + let name = if id.starts_with('v') && id.contains('.') { + id.into() + } else { + id.to_title_case().into() + }; + + let mut children = &mut self.outline; + let mut depth = *level as usize; + while depth > 2 { + if !children.is_empty() { + children = &mut children.last_mut().unwrap().children; + } + depth -= 1; + } + + children.push(OutlineItem { id: id.into(), name, children: vec![] }); + } + + fn handle_link(&self, link: &str) -> StrResult { + if let Some(link) = self.resolver.link(link) { + return Ok(link); + } + + crate::link::resolve(link, self.resolver.base()) + } + + fn nesting(&self) -> usize { + match self.nesting { + Some(nesting) => nesting, + None => panic!("headings are not allowed here:\n{}", self.text), + } + } +} + +/// Render a code block to HTML. +fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html { + let mut display = String::new(); + let mut compile = String::new(); + for line in text.lines() { + if let Some(suffix) = line.strip_prefix(">>>") { + compile.push_str(suffix); + compile.push('\n'); + } else if let Some(suffix) = line.strip_prefix("<<< ") { + display.push_str(suffix); + display.push('\n'); + } else { + display.push_str(line); + display.push('\n'); + compile.push_str(line); + compile.push('\n'); + } + } + + let mut parts = lang.split(':'); + let lang = parts.next().unwrap_or(lang); + + let mut zoom: Option<[Abs; 4]> = None; + let mut single = false; + if let Some(args) = parts.next() { + single = true; + if !args.contains("single") { + zoom = args + .split(',') + .take(4) + .map(|s| Abs::pt(s.parse().unwrap())) + .collect::>() + .try_into() + .ok(); + } + } + + if lang.is_empty() { + let mut buf = String::from("
");
+        md::escape::escape_html(&mut buf, &display).unwrap();
+        buf.push_str("
"); + return Html::new(buf); + } else if !matches!(lang, "example" | "typ" | "preview") { + let set = &*typst::text::RAW_SYNTAXES; + let buf = syntect::html::highlighted_html_for_string( + &display, + set, + set.find_syntax_by_token(lang) + .unwrap_or_else(|| panic!("unsupported highlighting language: {lang}")), + &typst::text::RAW_THEME, + ) + .expect("failed to highlight code"); + return Html::new(buf); + } + + let mut highlighted = None; + if matches!(lang, "example" | "typ") { + let root = typst::syntax::parse(&display); + let html = Html::new(typst::syntax::highlight_html(&root)); + if lang == "typ" { + return Html::new(format!("
{}
", html.as_str())); + } + highlighted = Some(html); + } + + let id = FileId::new(None, VirtualPath::new("main.typ")); + let source = Source::new(id, compile); + let world = DocWorld(source); + + let mut tracer = Tracer::new(); + let mut document = match typst::compile(&world, &mut tracer) { + Ok(doc) => doc, + Err(err) => { + let msg = &err[0].message; + panic!("while trying to compile:\n{text}:\n\nerror: {msg}"); + } + }; + + if let Some([x, y, w, h]) = zoom { + document.pages[0].frame.translate(Point::new(-x, -y)); + *document.pages[0].frame.size_mut() = Size::new(w, h); + } + + if single { + document.pages.truncate(1); + } + + let hash = typst::util::hash128(text); + resolver.example(hash, highlighted, &document) +} + +/// Extract an attribute value from an HTML element. +fn html_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> { + html.get(html_attr_range(html, attr)?) +} + +/// Extract the range of the attribute value of an HTML element. +fn html_attr_range(html: &str, attr: &str) -> Option> { + let needle = format!("{attr}=\""); + let offset = html.find(&needle)? + needle.len(); + let len = html[offset..].find('"')?; + Some(offset..offset + len) +} + +/// Increase the nesting level of a Markdown heading. +fn nest_heading(level: &mut md::HeadingLevel, nesting: usize) { + *level = ((*level as usize) + nesting) + .try_into() + .unwrap_or(md::HeadingLevel::H6); +} + +/// A world for example compilations. +struct DocWorld(Source); + +impl World for DocWorld { + fn library(&self) -> &Prehashed { + &LIBRARY + } + + fn book(&self) -> &Prehashed { + &FONTS.0 + } + + fn main(&self) -> Source { + self.0.clone() + } + + fn source(&self, _: FileId) -> FileResult { + Ok(self.0.clone()) + } + + fn file(&self, id: FileId) -> FileResult { + assert!(id.package().is_none()); + Ok(Bytes::from_static( + typst_dev_assets::get_by_name( + &id.vpath().as_rootless_path().to_string_lossy(), + ) + .unwrap_or_else(|| panic!("failed to load {:?}", id.vpath())), + )) + } + + fn font(&self, index: usize) -> Option { + Some(FONTS.1[index].clone()) + } + + fn today(&self, _: Option) -> Option { + Some(Datetime::from_ymd(1970, 1, 1).unwrap()) + } +} diff --git a/docs/src/lib.rs b/docs/src/lib.rs new file mode 100644 index 00000000..0bb9fc86 --- /dev/null +++ b/docs/src/lib.rs @@ -0,0 +1,803 @@ +//! Documentation provider for Typst. + +mod contribs; +mod html; +mod link; +mod model; + +pub use self::contribs::*; +pub use self::html::*; +pub use self::model::*; + +use comemo::Prehashed; +use ecow::{eco_format, EcoString}; +use heck::ToTitleCase; +use once_cell::sync::Lazy; +use serde::Deserialize; +use serde_yaml as yaml; +use typst::diag::{bail, StrResult}; +use typst::foundations::Bytes; +use typst::foundations::{ + CastInfo, Category, Func, Module, ParamInfo, Repr, Scope, Smart, Type, Value, + FOUNDATIONS, +}; +use typst::introspection::INTROSPECTION; +use typst::layout::{Abs, Margin, PageElem, LAYOUT}; +use typst::loading::DATA_LOADING; +use typst::math::MATH; +use typst::model::Document; +use typst::model::MODEL; +use typst::symbols::SYMBOLS; +use typst::text::{Font, FontBook, TEXT}; +use typst::visualize::VISUALIZE; +use typst::Library; + +macro_rules! load { + ($path:literal) => { + include_str!(concat!("../", $path)) + }; +} + +static GROUPS: Lazy> = Lazy::new(|| { + let mut groups: Vec = + yaml::from_str(load!("reference/groups.yml")).unwrap(); + for group in &mut groups { + if group.filter.is_empty() { + group.filter = group + .module() + .scope() + .iter() + .filter(|(_, v)| matches!(v, Value::Func(_))) + .map(|(k, _)| k.clone()) + .collect(); + } + } + groups +}); + +static LIBRARY: Lazy> = Lazy::new(|| { + let mut lib = Library::default(); + lib.styles + .set(PageElem::set_width(Smart::Custom(Abs::pt(240.0).into()))); + lib.styles.set(PageElem::set_height(Smart::Auto)); + lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom( + Abs::pt(15.0).into(), + ))))); + Prehashed::new(lib) +}); + +static FONTS: Lazy<(Prehashed, Vec)> = Lazy::new(|| { + let fonts: Vec<_> = typst_assets::fonts() + .chain(typst_dev_assets::fonts()) + .flat_map(|data| Font::iter(Bytes::from_static(data))) + .collect(); + let book = FontBook::from_fonts(&fonts); + (Prehashed::new(book), fonts) +}); + +/// Build documentation pages. +pub fn provide(resolver: &dyn Resolver) -> Vec { + let base = resolver.base(); + vec![ + md_page(resolver, base, load!("overview.md")).with_route(base), + tutorial_pages(resolver), + reference_pages(resolver), + guide_pages(resolver), + packages_page(resolver), + md_page(resolver, base, load!("changelog.md")), + md_page(resolver, base, load!("roadmap.md")), + md_page(resolver, base, load!("community.md")), + ] +} + +/// Resolve consumer dependencies. +pub trait Resolver { + /// Try to resolve a link that the system cannot resolve itself. + fn link(&self, link: &str) -> Option; + + /// Produce an URL for an image file. + fn image(&self, filename: &str, data: &[u8]) -> String; + + /// Produce HTML for an example. + fn example(&self, hash: u128, source: Option, document: &Document) -> Html; + + /// Determine the commits between two tags. + fn commits(&self, from: &str, to: &str) -> Vec; + + /// Get the base URL for the routes and links. This must end with a slash. + fn base(&self) -> &str; +} + +/// Create a page from a markdown file. +#[track_caller] +fn md_page(resolver: &dyn Resolver, parent: &str, md: &str) -> PageModel { + assert!(parent.starts_with('/') && parent.ends_with('/')); + let html = Html::markdown(resolver, md, Some(0)); + let title: EcoString = html.title().expect("chapter lacks a title").into(); + PageModel { + route: eco_format!("{parent}{}/", urlify(&title)), + title, + description: html.description().unwrap(), + part: None, + outline: html.outline(), + body: BodyModel::Html(html), + children: vec![], + } +} + +/// Build the tutorial. +fn tutorial_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = md_page(resolver, resolver.base(), load!("tutorial/welcome.md")); + let base = format!("{}tutorial/", resolver.base()); + page.children = vec![ + md_page(resolver, &base, load!("tutorial/1-writing.md")), + md_page(resolver, &base, load!("tutorial/2-formatting.md")), + md_page(resolver, &base, load!("tutorial/3-advanced.md")), + md_page(resolver, &base, load!("tutorial/4-template.md")), + ]; + page +} + +/// Build the reference. +fn reference_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = md_page(resolver, resolver.base(), load!("reference/welcome.md")); + let base = format!("{}reference/", resolver.base()); + page.children = vec![ + md_page(resolver, &base, load!("reference/syntax.md")).with_part("Language"), + md_page(resolver, &base, load!("reference/styling.md")), + md_page(resolver, &base, load!("reference/scripting.md")), + md_page(resolver, &base, load!("reference/context.md")), + category_page(resolver, FOUNDATIONS).with_part("Library"), + category_page(resolver, MODEL), + category_page(resolver, TEXT), + category_page(resolver, MATH), + category_page(resolver, SYMBOLS), + category_page(resolver, LAYOUT), + category_page(resolver, VISUALIZE), + category_page(resolver, INTROSPECTION), + category_page(resolver, DATA_LOADING), + ]; + page +} + +/// Build the guides section. +fn guide_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = md_page(resolver, resolver.base(), load!("guides/welcome.md")); + let base = format!("{}guides/", resolver.base()); + page.children = vec![ + md_page(resolver, &base, load!("guides/guide-for-latex-users.md")), + md_page(resolver, &base, load!("guides/page-setup.md")), + ]; + page +} + +/// Build the packages section. +fn packages_page(resolver: &dyn Resolver) -> PageModel { + PageModel { + route: eco_format!("{}packages/", resolver.base()), + title: "Packages".into(), + description: "Packages for Typst.".into(), + part: None, + outline: vec![], + body: BodyModel::Packages(Html::markdown( + resolver, + load!("reference/packages.md"), + Some(1), + )), + children: vec![], + } +} + +/// Create a page for a category. +#[track_caller] +fn category_page(resolver: &dyn Resolver, category: Category) -> PageModel { + let route = eco_format!("{}reference/{}/", resolver.base(), category.name()); + let mut children = vec![]; + let mut items = vec![]; + let mut shorthands = None; + let mut markup = vec![]; + let mut math = vec![]; + + let (module, path): (&Module, &[&str]) = if category == MATH { + (&LIBRARY.math, &["math"]) + } else { + (&LIBRARY.global, &[]) + }; + + // Add groups. + for group in GROUPS.iter().filter(|g| g.category == category.name()).cloned() { + if matches!(group.name.as_str(), "sym" | "emoji") { + let subpage = symbols_page(resolver, &route, &group); + let BodyModel::Symbols(model) = &subpage.body else { continue }; + let list = &model.list; + markup.extend( + list.iter() + .filter(|symbol| symbol.markup_shorthand.is_some()) + .cloned(), + ); + math.extend( + list.iter().filter(|symbol| symbol.math_shorthand.is_some()).cloned(), + ); + + items.push(CategoryItem { + name: group.name.clone(), + route: subpage.route.clone(), + oneliner: oneliner(category.docs()).into(), + code: true, + }); + children.push(subpage); + continue; + } + + let (child, item) = group_page(resolver, &route, &group); + children.push(child); + items.push(item); + } + + // Add symbol pages. These are ordered manually. + if category == SYMBOLS { + shorthands = Some(ShorthandsModel { markup, math }); + } + + // Add functions. + let scope = module.scope(); + for (name, value) in scope.iter() { + if scope.get_category(name) != Some(category) { + continue; + } + + if category == MATH { + // Skip grouped functions. + if GROUPS.iter().flat_map(|group| &group.filter).any(|f| f == name) { + continue; + } + + // Already documented in the text category. + if name == "text" { + continue; + } + } + + match value { + Value::Func(func) => { + let name = func.name().unwrap(); + + let subpage = func_page(resolver, &route, func, path); + items.push(CategoryItem { + name: name.into(), + route: subpage.route.clone(), + oneliner: oneliner(func.docs().unwrap_or_default()).into(), + code: true, + }); + children.push(subpage); + } + Value::Type(ty) => { + let subpage = type_page(resolver, &route, ty); + items.push(CategoryItem { + name: ty.short_name().into(), + route: subpage.route.clone(), + oneliner: oneliner(ty.docs()).into(), + code: true, + }); + children.push(subpage); + } + _ => {} + } + } + + if category != SYMBOLS { + children.sort_by_cached_key(|child| child.title.clone()); + items.sort_by_cached_key(|item| item.name.clone()); + } + + let name = category.title(); + let details = Html::markdown(resolver, category.docs(), Some(1)); + let mut outline = vec![OutlineItem::from_name("Summary")]; + outline.extend(details.outline()); + outline.push(OutlineItem::from_name("Definitions")); + if shorthands.is_some() { + outline.push(OutlineItem::from_name("Shorthands")); + } + + PageModel { + route, + title: name.into(), + description: eco_format!( + "Documentation for functions related to {name} in Typst." + ), + part: None, + outline, + body: BodyModel::Category(CategoryModel { + name: category.name(), + title: category.title(), + details, + items, + shorthands, + }), + children, + } +} + +/// Create a page for a function. +fn func_page( + resolver: &dyn Resolver, + parent: &str, + func: &Func, + path: &[&str], +) -> PageModel { + let model = func_model(resolver, func, path, false); + let name = func.name().unwrap(); + PageModel { + route: eco_format!("{parent}{}/", urlify(name)), + title: func.title().unwrap().into(), + description: eco_format!("Documentation for the `{name}` function."), + part: None, + outline: func_outline(&model, ""), + body: BodyModel::Func(model), + children: vec![], + } +} + +/// Produce a function's model. +fn func_model( + resolver: &dyn Resolver, + func: &Func, + path: &[&str], + nested: bool, +) -> FuncModel { + let name = func.name().unwrap(); + let scope = func.scope().unwrap(); + let docs = func.docs().unwrap(); + + let mut self_ = false; + let mut params = func.params().unwrap(); + if params.first().map_or(false, |first| first.name == "self") { + self_ = true; + params = ¶ms[1..]; + } + + let mut returns = vec![]; + casts(resolver, &mut returns, &mut vec![], func.returns().unwrap()); + returns.sort_by_key(|ty| type_index(ty)); + if returns == ["none"] { + returns.clear(); + } + + let nesting = if nested { None } else { Some(1) }; + let (details, example) = + if nested { split_details_and_example(docs) } else { (docs, None) }; + + FuncModel { + path: path.iter().copied().map(Into::into).collect(), + name: name.into(), + title: func.title().unwrap(), + keywords: func.keywords(), + oneliner: oneliner(details), + element: func.element().is_some(), + contextual: func.contextual().unwrap_or(false), + details: Html::markdown(resolver, details, nesting), + example: example.map(|md| Html::markdown(resolver, md, None)), + self_, + params: params.iter().map(|param| param_model(resolver, param)).collect(), + returns, + scope: scope_models(resolver, name, scope), + } +} + +/// Produce a parameter's model. +fn param_model(resolver: &dyn Resolver, info: &ParamInfo) -> ParamModel { + let (details, example) = split_details_and_example(info.docs); + + let mut types = vec![]; + let mut strings = vec![]; + casts(resolver, &mut types, &mut strings, &info.input); + if !strings.is_empty() && !types.contains(&"str") { + types.push("str"); + } + types.sort_by_key(|ty| type_index(ty)); + + ParamModel { + name: info.name, + details: Html::markdown(resolver, details, None), + example: example.map(|md| Html::markdown(resolver, md, None)), + types, + strings, + default: info.default.map(|default| { + let node = typst::syntax::parse_code(&default().repr()); + Html::new(typst::syntax::highlight_html(&node)) + }), + positional: info.positional, + named: info.named, + required: info.required, + variadic: info.variadic, + settable: info.settable, + } +} + +/// Split up documentation into details and an example. +fn split_details_and_example(docs: &str) -> (&str, Option<&str>) { + let mut details = docs; + let mut example = None; + if let Some(mut i) = docs.find("```") { + while docs[..i].ends_with('`') { + i -= 1; + } + details = &docs[..i]; + example = Some(&docs[i..]); + } + (details, example) +} + +/// Process cast information into types and strings. +fn casts( + resolver: &dyn Resolver, + types: &mut Vec<&'static str>, + strings: &mut Vec, + info: &CastInfo, +) { + match info { + CastInfo::Any => types.push("any"), + CastInfo::Value(Value::Str(string), docs) => strings.push(StrParam { + string: string.clone().into(), + details: Html::markdown(resolver, docs, None), + }), + CastInfo::Value(..) => {} + CastInfo::Type(ty) => types.push(ty.short_name()), + CastInfo::Union(options) => { + for option in options { + casts(resolver, types, strings, option); + } + } + } +} + +/// Produce models for a function's scope. +fn scope_models(resolver: &dyn Resolver, name: &str, scope: &Scope) -> Vec { + scope + .iter() + .filter_map(|(_, value)| { + let Value::Func(func) = value else { return None }; + Some(func_model(resolver, func, &[name], true)) + }) + .collect() +} + +/// Produce an outline for a function page. +fn func_outline(model: &FuncModel, id_base: &str) -> Vec { + let mut outline = vec![]; + + if id_base.is_empty() { + outline.push(OutlineItem::from_name("Summary")); + outline.extend(model.details.outline()); + + if !model.params.is_empty() { + outline.push(OutlineItem { + id: "parameters".into(), + name: "Parameters".into(), + children: model + .params + .iter() + .map(|param| OutlineItem { + id: eco_format!("parameters-{}", urlify(param.name)), + name: param.name.into(), + children: vec![], + }) + .collect(), + }); + } + + outline.extend(scope_outline(&model.scope)); + } else { + outline.extend(model.params.iter().map(|param| OutlineItem { + id: eco_format!("{id_base}-{}", urlify(param.name)), + name: param.name.into(), + children: vec![], + })); + } + + outline +} + +/// Produce an outline for a function scope. +fn scope_outline(scope: &[FuncModel]) -> Option { + if scope.is_empty() { + return None; + } + + Some(OutlineItem { + id: "definitions".into(), + name: "Definitions".into(), + children: scope + .iter() + .map(|func| { + let id = urlify(&eco_format!("definitions-{}", func.name)); + let children = func_outline(func, &id); + OutlineItem { id, name: func.title.into(), children } + }) + .collect(), + }) +} + +/// Create a page for a group of functions. +fn group_page( + resolver: &dyn Resolver, + parent: &str, + group: &GroupData, +) -> (PageModel, CategoryItem) { + let mut functions = vec![]; + let mut outline = vec![OutlineItem::from_name("Summary")]; + + let path: Vec<_> = group.path.iter().map(|s| s.as_str()).collect(); + let details = Html::markdown(resolver, &group.details, Some(1)); + outline.extend(details.outline()); + + let mut outline_items = vec![]; + for name in &group.filter { + let value = group.module().scope().get(name).unwrap(); + let Value::Func(func) = value else { panic!("not a function") }; + let func = func_model(resolver, func, &path, true); + let id_base = urlify(&eco_format!("functions-{}", func.name)); + let children = func_outline(&func, &id_base); + outline_items.push(OutlineItem { + id: id_base, + name: func.title.into(), + children, + }); + functions.push(func); + } + + outline.push(OutlineItem { + id: "functions".into(), + name: "Functions".into(), + children: outline_items, + }); + + let model = PageModel { + route: eco_format!("{parent}{}", group.name), + title: group.title.clone(), + description: eco_format!("Documentation for the {} functions.", group.name), + part: None, + outline, + body: BodyModel::Group(GroupModel { + name: group.name.clone(), + title: group.title.clone(), + details, + functions, + }), + children: vec![], + }; + + let item = CategoryItem { + name: group.name.clone(), + route: model.route.clone(), + oneliner: oneliner(&group.details).into(), + code: false, + }; + + (model, item) +} + +/// Create a page for a type. +fn type_page(resolver: &dyn Resolver, parent: &str, ty: &Type) -> PageModel { + let model = type_model(resolver, ty); + PageModel { + route: eco_format!("{parent}{}/", urlify(ty.short_name())), + title: ty.title().into(), + description: eco_format!("Documentation for the {} type.", ty.title()), + part: None, + outline: type_outline(&model), + body: BodyModel::Type(model), + children: vec![], + } +} + +/// Produce a type's model. +fn type_model(resolver: &dyn Resolver, ty: &Type) -> TypeModel { + TypeModel { + name: ty.short_name(), + title: ty.title(), + keywords: ty.keywords(), + oneliner: oneliner(ty.docs()), + details: Html::markdown(resolver, ty.docs(), Some(1)), + constructor: ty + .constructor() + .ok() + .map(|func| func_model(resolver, &func, &[], true)), + scope: scope_models(resolver, ty.short_name(), ty.scope()), + } +} + +/// Produce an outline for a type page. +fn type_outline(model: &TypeModel) -> Vec { + let mut outline = vec![OutlineItem::from_name("Summary")]; + outline.extend(model.details.outline()); + + if let Some(func) = &model.constructor { + outline.push(OutlineItem { + id: "constructor".into(), + name: "Constructor".into(), + children: func_outline(func, "constructor"), + }); + } + + outline.extend(scope_outline(&model.scope)); + outline +} + +/// Create a page for symbols. +fn symbols_page(resolver: &dyn Resolver, parent: &str, group: &GroupData) -> PageModel { + let model = symbols_model(resolver, group); + PageModel { + route: eco_format!("{parent}{}/", group.name), + title: group.title.clone(), + description: eco_format!("Documentation for the `{}` module.", group.name), + part: None, + outline: vec![], + body: BodyModel::Symbols(model), + children: vec![], + } +} + +/// Produce a symbol list's model. +fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { + let mut list = vec![]; + for (name, value) in group.module().scope().iter() { + let Value::Symbol(symbol) = value else { continue }; + let complete = |variant: &str| { + if variant.is_empty() { + name.clone() + } else { + eco_format!("{}.{}", name, variant) + } + }; + + for (variant, c) in symbol.variants() { + let shorthand = |list: &[(&'static str, char)]| { + list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) + }; + + list.push(SymbolModel { + name: complete(variant), + markup_shorthand: shorthand(typst::syntax::ast::Shorthand::MARKUP_LIST), + math_shorthand: shorthand(typst::syntax::ast::Shorthand::MATH_LIST), + codepoint: c as u32, + accent: typst::symbols::Symbol::combining_accent(c).is_some(), + unicode_name: unicode_names2::name(c) + .map(|s| s.to_string().to_title_case().into()), + alternates: symbol + .variants() + .filter(|(other, _)| other != &variant) + .map(|(other, _)| complete(other)) + .collect(), + }); + } + } + + SymbolsModel { + name: group.name.clone(), + title: group.title.clone(), + details: Html::markdown(resolver, &group.details, Some(1)), + list, + } +} + +/// Extract a module from another module. +#[track_caller] +fn get_module<'a>(parent: &'a Module, name: &str) -> StrResult<&'a Module> { + match parent.scope().get(name) { + Some(Value::Module(module)) => Ok(module), + _ => bail!("module doesn't contain module `{name}`"), + } +} + +/// Turn a title into an URL fragment. +pub fn urlify(title: &str) -> EcoString { + title + .chars() + .map(|c| c.to_ascii_lowercase()) + .map(|c| match c { + 'a'..='z' | '0'..='9' => c, + _ => '-', + }) + .collect() +} + +/// Extract the first line of documentation. +fn oneliner(docs: &str) -> &str { + docs.lines().next().unwrap_or_default() +} + +/// The order of types in the documentation. +fn type_index(ty: &str) -> usize { + TYPE_ORDER.iter().position(|&v| v == ty).unwrap_or(usize::MAX) +} + +const TYPE_ORDER: &[&str] = &[ + "any", + "none", + "auto", + "bool", + "int", + "float", + "length", + "angle", + "ratio", + "relative", + "fraction", + "color", + "gradient", + "datetime", + "duration", + "str", + "bytes", + "regex", + "label", + "content", + "array", + "dict", + "func", + "args", + "selector", + "location", + "direction", + "alignment", + "alignment2d", + "stroke", +]; + +/// Data about a collection of functions. +#[derive(Debug, Clone, Deserialize)] +struct GroupData { + name: EcoString, + title: EcoString, + category: EcoString, + #[serde(default)] + path: Vec, + #[serde(default)] + filter: Vec, + details: EcoString, +} + +impl GroupData { + fn module(&self) -> &'static Module { + let mut focus = &LIBRARY.global; + for path in &self.path { + focus = get_module(focus, path).unwrap(); + } + focus + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_docs() { + provide(&TestResolver); + } + + struct TestResolver; + + impl Resolver for TestResolver { + fn link(&self, _: &str) -> Option { + None + } + + fn example(&self, _: u128, _: Option, _: &Document) -> Html { + Html::new(String::new()) + } + + fn image(&self, _: &str, _: &[u8]) -> String { + String::new() + } + + fn commits(&self, _: &str, _: &str) -> Vec { + vec![] + } + + fn base(&self) -> &str { + "/" + } + } +} diff --git a/docs/src/link.rs b/docs/src/link.rs new file mode 100644 index 00000000..f4d803c3 --- /dev/null +++ b/docs/src/link.rs @@ -0,0 +1,108 @@ +use typst::diag::{bail, StrResult}; +use typst::foundations::Func; + +use crate::{get_module, GROUPS, LIBRARY}; + +/// Resolve an intra-doc link. +pub fn resolve(link: &str, base: &str) -> StrResult { + if link.starts_with('#') || link.starts_with("http") { + return Ok(link.to_string()); + } + + let (head, tail) = split_link(link)?; + let mut route = match resolve_known(head, base) { + Some(route) => route, + None => resolve_definition(head, base)?, + }; + + if !tail.is_empty() { + route.push('/'); + route.push_str(tail); + } + + if !route.contains('#') && !route.ends_with('/') { + route.push('/'); + } + + Ok(route) +} + +/// Split a link at the first slash. +fn split_link(link: &str) -> StrResult<(&str, &str)> { + let first = link.split('/').next().unwrap_or(link); + let rest = link[first.len()..].trim_start_matches('/'); + Ok((first, rest)) +} + +/// Resolve a `$` link head to a known destination. +fn resolve_known(head: &str, base: &str) -> Option { + Some(match head { + "$tutorial" => format!("{base}tutorial"), + "$reference" => format!("{base}reference"), + "$category" => format!("{base}reference"), + "$syntax" => format!("{base}reference/syntax"), + "$styling" => format!("{base}reference/styling"), + "$scripting" => format!("{base}reference/scripting"), + "$context" => format!("{base}reference/context"), + "$guides" => format!("{base}guides"), + "$packages" => format!("{base}packages"), + "$changelog" => format!("{base}changelog"), + "$community" => format!("{base}community"), + _ => return None, + }) +} + +/// Resolve a `$` link to a global definition. +fn resolve_definition(head: &str, base: &str) -> StrResult { + let mut parts = head.trim_start_matches('$').split('.').peekable(); + let mut focus = &LIBRARY.global; + let mut category = None; + + while let Some(name) = parts.peek() { + if category.is_none() { + category = focus.scope().get_category(name); + } + let Ok(module) = get_module(focus, name) else { break }; + focus = module; + parts.next(); + } + + let Some(category) = category else { bail!("{head} has no category") }; + + let name = parts.next().ok_or("link is missing first part")?; + let value = focus.field(name)?; + + // Handle grouped functions. + if let Some(group) = GROUPS.iter().find(|group| { + group.category == category.name() && group.filter.iter().any(|func| func == name) + }) { + let mut route = format!( + "{}reference/{}/{}/#functions-{}", + base, group.category, group.name, name + ); + if let Some(param) = parts.next() { + route.push('-'); + route.push_str(param); + } + return Ok(route); + } + + let mut route = format!("{}reference/{}/{name}/", base, category.name()); + if let Some(next) = parts.next() { + if value.field(next).is_ok() { + route.push_str("#definitions-"); + route.push_str(next); + } else if value + .clone() + .cast::() + .map_or(false, |func| func.param(next).is_some()) + { + route.push_str("#parameters-"); + route.push_str(next); + } else { + bail!("field {next} not found"); + } + } + + Ok(route) +} diff --git a/docs/src/main.rs b/docs/src/main.rs new file mode 100644 index 00000000..f4414b10 --- /dev/null +++ b/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(()) +} diff --git a/docs/src/model.rs b/docs/src/model.rs new file mode 100644 index 00000000..1564ef2f --- /dev/null +++ b/docs/src/model.rs @@ -0,0 +1,173 @@ +use ecow::EcoString; +use heck::ToKebabCase; +use serde::Serialize; + +use crate::html::Html; + +/// Details about a documentation page and its children. +#[derive(Debug, Serialize)] +pub struct PageModel { + pub route: EcoString, + pub title: EcoString, + pub description: EcoString, + pub part: Option<&'static str>, + pub outline: Vec, + pub body: BodyModel, + pub children: Vec, +} + +impl PageModel { + pub fn with_route(self, route: &str) -> Self { + Self { route: route.into(), ..self } + } + + pub fn with_part(self, part: &'static str) -> Self { + Self { part: Some(part), ..self } + } +} + +/// An element in the "On This Page" outline. +#[derive(Debug, Clone, Serialize)] +pub struct OutlineItem { + pub id: EcoString, + pub name: EcoString, + pub children: Vec, +} + +impl OutlineItem { + /// Create an outline item from a name with auto-generated id. + pub fn from_name(name: &str) -> Self { + Self { + id: name.to_kebab_case().into(), + name: name.into(), + children: vec![], + } + } +} + +/// Details about the body of a documentation page. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "kind", content = "content")] +pub enum BodyModel { + Html(Html), + Category(CategoryModel), + Func(FuncModel), + Group(GroupModel), + Type(TypeModel), + Symbols(SymbolsModel), + Packages(Html), +} + +/// Details about a category. +#[derive(Debug, Serialize)] +pub struct CategoryModel { + pub name: &'static str, + pub title: &'static str, + pub details: Html, + pub items: Vec, + pub shorthands: Option, +} + +/// Details about a category item. +#[derive(Debug, Serialize)] +pub struct CategoryItem { + pub name: EcoString, + pub route: EcoString, + pub oneliner: EcoString, + pub code: bool, +} + +/// Details about a function. +#[derive(Debug, Serialize)] +pub struct FuncModel { + pub path: Vec, + pub name: EcoString, + pub title: &'static str, + pub keywords: &'static [&'static str], + pub oneliner: &'static str, + pub element: bool, + pub contextual: bool, + pub details: Html, + /// This example is only for nested function models. Others can have + /// their example directly in their details. + pub example: Option, + #[serde(rename = "self")] + pub self_: bool, + pub params: Vec, + pub returns: Vec<&'static str>, + pub scope: Vec, +} + +/// Details about a function parameter. +#[derive(Debug, Serialize)] +pub struct ParamModel { + pub name: &'static str, + pub details: Html, + pub example: Option, + pub types: Vec<&'static str>, + pub strings: Vec, + pub default: Option, + pub positional: bool, + pub named: bool, + pub required: bool, + pub variadic: bool, + pub settable: bool, +} + +/// A specific string that can be passed as an argument. +#[derive(Debug, Serialize)] +pub struct StrParam { + pub string: EcoString, + pub details: Html, +} + +/// Details about a group of functions. +#[derive(Debug, Serialize)] +pub struct GroupModel { + pub name: EcoString, + pub title: EcoString, + pub details: Html, + pub functions: Vec, +} + +/// Details about a type. +#[derive(Debug, Serialize)] +pub struct TypeModel { + pub name: &'static str, + pub title: &'static str, + pub keywords: &'static [&'static str], + pub oneliner: &'static str, + pub details: Html, + pub constructor: Option, + pub scope: Vec, +} + +/// A collection of symbols. +#[derive(Debug, Serialize)] +pub struct SymbolsModel { + pub name: EcoString, + pub title: EcoString, + pub details: Html, + pub list: Vec, +} + +/// Details about a symbol. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SymbolModel { + pub name: EcoString, + pub codepoint: u32, + pub accent: bool, + pub unicode_name: Option, + pub alternates: Vec, + pub markup_shorthand: Option<&'static str>, + pub math_shorthand: Option<&'static str>, +} + +/// Shorthands listed on a category page. +#[derive(Debug, Serialize)] +pub struct ShorthandsModel { + pub markup: Vec, + pub math: Vec, +} diff --git a/docs/tutorial/1-writing.md b/docs/tutorial/1-writing.md index 23fdb675..de2af62f 100644 --- a/docs/tutorial/1-writing.md +++ b/docs/tutorial/1-writing.md @@ -9,7 +9,7 @@ you create a new project on the Typst app. You'll be taken to the editor where you see two panels: A source panel where you compose your document and a preview panel where you see the rendered document. -![Typst app screenshot](screenshots/1-writing-app.png) +![Typst app screenshot](1-writing-app.png) You already have a good angle for your report in mind. So let's start by writing the introduction. Enter some text in the editor panel. You'll notice that the @@ -77,7 +77,7 @@ click the button with the arrow in the top-right corner. This opens the upload dialog, in which you can pick files to upload from your computer. Select an image file for your report. -![Upload dialog](screenshots/1-writing-upload.png) +![Upload dialog](1-writing-upload.png) We have seen before that specific symbols (called _markup_) have specific meaning in Typst. We can use `=`, `-`, `+`, and `_` to create headings, lists diff --git a/docs/tutorial/2-formatting.md b/docs/tutorial/2-formatting.md index a0fb3a98..58095f94 100644 --- a/docs/tutorial/2-formatting.md +++ b/docs/tutorial/2-formatting.md @@ -82,7 +82,7 @@ hitting the Escape key and opened again by typing `#` or hitting right arguments for functions. Most suggestions come with a small description of what they do. -![Autocomplete panel](screenshots/2-formatting-autocomplete.png) +![Autocomplete panel](2-formatting-autocomplete.png) ## Set up the page { #page-setup } Back to set rules: When writing a rule, you choose the function depending on diff --git a/docs/tutorial/3-advanced.md b/docs/tutorial/3-advanced.md index 92aafbad..2393a474 100644 --- a/docs/tutorial/3-advanced.md +++ b/docs/tutorial/3-advanced.md @@ -16,7 +16,7 @@ left toolbar and create a team. Finally, click on the new team and go to its settings by clicking 'manage team' next to the team name. Now you can invite your supervisor by email. -![The team settings](screenshots/3-advanced-team-settings.png) +![The team settings](3-advanced-team-settings.png) Next, move your project into the team: Open it, going to its settings by choosing the gear icon in the left toolbar and selecting your new team from the @@ -489,7 +489,7 @@ We are now in compliance with all of these styles and can submit the paper to the conference! The finished paper looks like this: The finished paper -- cgit v1.2.3