diff options
Diffstat (limited to 'crates/typst-docs')
| -rw-r--r-- | crates/typst-docs/Cargo.toml | 27 | ||||
| -rw-r--r-- | crates/typst-docs/src/contribs.rs | 86 | ||||
| -rw-r--r-- | crates/typst-docs/src/html.rs | 513 | ||||
| -rw-r--r-- | crates/typst-docs/src/lib.rs | 1002 |
4 files changed, 1628 insertions, 0 deletions
diff --git a/crates/typst-docs/Cargo.toml b/crates/typst-docs/Cargo.toml new file mode 100644 index 00000000..47088815 --- /dev/null +++ b/crates/typst-docs/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "typst-docs" +version.workspace = true +rust-version.workspace = true +authors.workspace = true +edition.workspace = true +publish = false + +[lib] +doctest = false +bench = false + +[dependencies] +typst = { path = "../typst" } +typst-library = { path = "../typst-library" } +comemo = "0.3" +heck = "0.4" +include_dir = "0.7" +once_cell = "1" +pulldown-cmark = "0.9" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.8" +syntect = { version = "5", default-features = false, features = ["parsing", "html", "regex-fancy"] } +typed-arena = "2" +unicode_names2 = "0.6.0" +unscanny = "0.1" +yaml-front-matter = "0.1" diff --git a/crates/typst-docs/src/contribs.rs b/crates/typst-docs/src/contribs.rs new file mode 100644 index 00000000..901b5f69 --- /dev/null +++ b/crates/typst-docs/src/contribs.rs @@ -0,0 +1,86 @@ +use std::cmp::Reverse; +use std::collections::HashMap; +use std::fmt::Write; + +use serde::Deserialize; + +use super::{Html, Resolver}; + +/// Build HTML detailing the contributors between two tags. +pub fn contributors(resolver: &dyn Resolver, from: &str, to: &str) -> Option<Html> { + let staff = ["laurmaedje", "reknih"]; + + // Determine number of contributions per person. + let mut contributors = HashMap::<String, Contributor>::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 += "<ul class=\"contribs\">"; + + 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#"<li> + <a href="https://github.com/{login}" target="_blank"> + <img + width="64" + height="64" + src="{avatar}" + alt="GitHub avatar of {login}" + title="@{login} made {contributions} contribution{s}" + crossorigin="anonymous" + > + </a> + </li>"# + ) + .unwrap(); + } + + html += "</ul>"; + + Some(Html::new(html)) +} + +#[derive(Debug)] +struct Contributor { + login: String, + avatar: String, + contributions: usize, +} + +/// A commit on the `typst` repository. +#[derive(Debug, Deserialize)] +pub struct Commit { + author: Author, +} + +/// A commit author. +#[derive(Debug, 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 new file mode 100644 index 00000000..0d40f46b --- /dev/null +++ b/crates/typst-docs/src/html.rs @@ -0,0 +1,513 @@ +use std::ops::Range; + +use comemo::Prehashed; +use pulldown_cmark as md; +use typed_arena::Arena; +use typst::diag::FileResult; +use typst::eval::Datetime; +use typst::file::FileId; +use typst::font::{Font, FontBook}; +use typst::geom::{Point, Size}; +use typst::syntax::Source; +use typst::util::Bytes; +use typst::World; +use yaml_front_matter::YamlFrontMatter; + +use super::*; + +/// HTML documentation. +#[derive(Serialize)] +#[serde(transparent)] +pub struct Html { + raw: String, + #[serde(skip)] + md: String, + #[serde(skip)] + description: Option<String>, + #[serde(skip)] + outline: Vec<OutlineItem>, +} + +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) -> Self { + Self::markdown_with_id_base(resolver, md, "") + } + + /// Convert markdown to HTML, preceding all fragment identifiers with the + /// `id_base`. + #[track_caller] + pub fn markdown_with_id_base( + resolver: &dyn Resolver, + md: &str, + id_base: &str, + ) -> Self { + let mut text = md; + let mut description = None; + let document = YamlFrontMatter::parse::<Metadata>(md); + if let Ok(document) = &document { + text = &document.content; + description = Some(document.metadata.description.clone()) + } + + let options = md::Options::ENABLE_TABLES | md::Options::ENABLE_HEADING_ATTRIBUTES; + + let ids = Arena::new(); + let mut handler = Handler::new(resolver, id_base.into(), &ids); + let iter = md::Parser::new_ext(text, options) + .filter_map(|mut event| handler.handle(&mut event).then_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("<h1>").then(|| s.eat_until("</h1>")) + } + + /// The outline of the HTML. + pub fn outline(&self) -> Vec<OutlineItem> { + self.outline.clone() + } + + /// The description from the front matter. + pub fn description(&self) -> Option<String> { + 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: String, +} + +struct Handler<'a> { + resolver: &'a dyn Resolver, + lang: Option<String>, + code: String, + outline: Vec<OutlineItem>, + id_base: String, + ids: &'a Arena<String>, +} + +impl<'a> Handler<'a> { + fn new(resolver: &'a dyn Resolver, id_base: String, ids: &'a Arena<String>) -> Self { + Self { + resolver, + lang: None, + code: String::new(), + outline: vec![], + id_base, + 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("<img") => { + 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, Some(id), _)) => { + self.handle_heading(id, level); + } + + // Also handle heading closings. + md::Event::End(md::Tag::Heading(level, Some(_), _)) => { + if *level > md::HeadingLevel::H1 && !self.id_base.is_empty() { + nest_heading(level); + } + } + + // Rewrite contributor sections. + md::Event::Html(html) if html.starts_with("<contributors") => { + 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 = self + .handle_link(dest) + .unwrap_or_else(|| panic!("invalid link: {dest}")) + .into(); + } + + // 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::ide::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 = String::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(file) = FILES.get_file(link) { + self.resolver.image(link, file.contents()) + } else if let Some(url) = self.resolver.link(link) { + url + } else { + panic!("missing image: {link}") + } + } + + fn handle_heading(&mut self, id: &mut &'a str, level: &mut md::HeadingLevel) { + if *level == md::HeadingLevel::H1 { + return; + } + + // Special case for things like "v0.3.0". + let name = if id.starts_with('v') && id.contains('.') { + id.to_string() + } else { + id.to_title_case() + }; + + 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; + } + + // Put base before id. + if !self.id_base.is_empty() { + nest_heading(level); + *id = self.ids.alloc(format!("{}-{id}", self.id_base)).as_str(); + } + + children.push(OutlineItem { id: id.to_string(), name, children: vec![] }); + } + + fn handle_link(&self, link: &str) -> Option<String> { + if link.starts_with('#') || link.starts_with("http") { + return Some(link.into()); + } + + if !link.starts_with('$') { + return self.resolver.link(link); + } + + let root = link.split('/').next()?; + let rest = &link[root.len()..].trim_matches('/'); + let base = match root { + "$tutorial" => "/docs/tutorial/", + "$reference" => "/docs/reference/", + "$category" => "/docs/reference/", + "$syntax" => "/docs/reference/syntax/", + "$styling" => "/docs/reference/styling/", + "$scripting" => "/docs/reference/scripting/", + "$types" => "/docs/reference/types/", + "$type" => "/docs/reference/types/", + "$func" => "/docs/reference/", + "$guides" => "/docs/guides/", + "$packages" => "/docs/packages/", + "$changelog" => "/docs/changelog/", + "$community" => "/docs/community/", + _ => panic!("unknown link root: {root}"), + }; + + let mut route = base.to_string(); + if root == "$type" && rest.contains('.') { + let mut parts = rest.split('.'); + let ty = parts.next()?; + let method = parts.next()?; + route.push_str(ty); + route.push_str("/#methods-"); + route.push_str(method); + } else if root == "$func" { + let mut parts = rest.split('.').peekable(); + let first = parts.peek().copied(); + let mut focus = &LIBRARY.global; + while let Some(m) = first.and_then(|name| module(focus, name).ok()) { + focus = m; + parts.next(); + } + + let name = parts.next()?; + + let value = focus.get(name).ok()?; + let Value::Func(func) = value else { return None }; + let info = func.info()?; + route.push_str(info.category); + route.push('/'); + + if let Some(group) = GROUPS + .iter() + .filter(|_| first == Some("math")) + .find(|group| group.functions.iter().any(|func| func == info.name)) + { + route.push_str(&group.name); + route.push_str("/#"); + route.push_str(info.name); + if let Some(param) = parts.next() { + route.push_str("-parameters-"); + route.push_str(param); + } + } else { + route.push_str(name); + route.push('/'); + if let Some(next) = parts.next() { + if info.params.iter().any(|param| param.name == next) { + route.push_str("#parameters-"); + route.push_str(next); + } else if info.scope.iter().any(|(name, _)| name == next) { + route.push('#'); + route.push_str(info.name); + route.push('-'); + route.push_str(next); + } else { + return None; + } + } + } + } else { + route.push_str(rest); + } + + if !route.contains('#') && !route.ends_with('/') { + route.push('/'); + } + + Some(route) + } +} + +/// 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::<Vec<_>>() + .try_into() + .ok(); + } + } + + if lang.is_empty() { + let mut buf = String::from("<pre>"); + md::escape::escape_html(&mut buf, &display).unwrap(); + buf.push_str("</pre>"); + return Html::new(buf); + } else if !matches!(lang, "example" | "typ") { + let set = &*typst_library::text::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_library::text::THEME, + ) + .expect("failed to highlight code"); + return Html::new(buf); + } + + let root = typst::syntax::parse(&display); + let highlighted = Html::new(typst::ide::highlight_html(&root)); + if lang == "typ" { + return Html::new(format!("<pre>{}</pre>", highlighted.as_str())); + } + + let id = FileId::new(None, Path::new("/main.typ")); + let source = Source::new(id, compile); + let world = DocWorld(source); + let mut frames = match typst::compile(&world) { + Ok(doc) => doc.pages, + 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 { + frames[0].translate(Point::new(-x, -y)); + *frames[0].size_mut() = Size::new(w, h); + } + + if single { + frames.truncate(1); + } + + resolver.example(highlighted, &frames) +} + +/// 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<Range<usize>> { + 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) { + *level = match &level { + md::HeadingLevel::H1 => md::HeadingLevel::H2, + md::HeadingLevel::H2 => md::HeadingLevel::H3, + md::HeadingLevel::H3 => md::HeadingLevel::H4, + md::HeadingLevel::H4 => md::HeadingLevel::H5, + md::HeadingLevel::H5 => md::HeadingLevel::H6, + v => **v, + }; +} + +/// A world for example compilations. +struct DocWorld(Source); + +impl World for DocWorld { + fn library(&self) -> &Prehashed<Library> { + &LIBRARY + } + + fn book(&self) -> &Prehashed<FontBook> { + &FONTS.0 + } + + fn main(&self) -> Source { + self.0.clone() + } + + fn source(&self, _: FileId) -> FileResult<Source> { + Ok(self.0.clone()) + } + + fn file(&self, id: FileId) -> FileResult<Bytes> { + assert!(id.package().is_none()); + Ok(FILES + .get_file(id.path().strip_prefix("/").unwrap()) + .unwrap_or_else(|| panic!("failed to load {:?}", id.path().display())) + .contents() + .into()) + } + + fn font(&self, index: usize) -> Option<Font> { + Some(FONTS.1[index].clone()) + } + + fn today(&self, _: Option<i64>) -> Option<Datetime> { + Some(Datetime::from_ymd(1970, 1, 1).unwrap()) + } +} diff --git a/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs new file mode 100644 index 00000000..d7f816d2 --- /dev/null +++ b/crates/typst-docs/src/lib.rs @@ -0,0 +1,1002 @@ +//! Documentation provider for Typst. + +mod contribs; +mod html; + +pub use contribs::{contributors, Author, Commit}; +pub use html::Html; + +use std::fmt::{self, Debug, Formatter}; +use std::path::Path; + +use comemo::Prehashed; +use heck::ToTitleCase; +use include_dir::{include_dir, Dir}; +use once_cell::sync::Lazy; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_yaml as yaml; +use typst::doc::Frame; +use typst::eval::{CastInfo, Func, FuncInfo, Library, Module, ParamInfo, Value}; +use typst::font::{Font, FontBook}; +use typst::geom::{Abs, Smart}; +use typst_library::layout::{Margin, PageElem}; +use unscanny::Scanner; + +static DOCS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../docs"); +static FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/files"); +static DETAILS: Lazy<yaml::Mapping> = Lazy::new(|| yaml("reference/details.yml")); +static GROUPS: Lazy<Vec<GroupData>> = Lazy::new(|| yaml("reference/groups.yml")); + +static FONTS: Lazy<(Prehashed<FontBook>, Vec<Font>)> = Lazy::new(|| { + static DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/fonts"); + let fonts: Vec<_> = DIR + .files() + .flat_map(|file| Font::iter(file.contents().into())) + .collect(); + let book = FontBook::from_fonts(&fonts); + (Prehashed::new(book), fonts) +}); + +static LIBRARY: Lazy<Prehashed<Library>> = Lazy::new(|| { + let mut lib = typst_library::build(); + 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(), + ))))); + typst::eval::set_lang_items(lib.items.clone()); + Prehashed::new(lib) +}); + +/// Build documentation pages. +pub fn provide(resolver: &dyn Resolver) -> Vec<PageModel> { + vec![ + markdown_page(resolver, "/docs/", "overview.md").with_route("/docs/"), + tutorial_pages(resolver), + reference_pages(resolver), + guides_pages(resolver), + packages_page(resolver), + markdown_page(resolver, "/docs/", "changelog.md"), + markdown_page(resolver, "/docs/", "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<String>; + + /// Produce an URL for an image file. + fn image(&self, filename: &str, data: &[u8]) -> String; + + /// Produce HTML for an example. + fn example(&self, source: Html, frames: &[Frame]) -> Html; + + /// Determine the commits between two tags. + fn commits(&self, from: &str, to: &str) -> Vec<Commit>; +} + +/// Details about a documentation page and its children. +#[derive(Debug, Serialize)] +pub struct PageModel { + pub route: String, + pub title: String, + pub description: String, + pub part: Option<&'static str>, + pub outline: Vec<OutlineItem>, + pub body: BodyModel, + pub children: Vec<Self>, +} + +impl PageModel { + fn with_route(self, route: &str) -> Self { + Self { route: route.into(), ..self } + } + + 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 { + id: String, + name: String, + children: Vec<Self>, +} + +/// 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), + Funcs(FuncsModel), + Type(TypeModel), + Symbols(SymbolsModel), + Packages(Html), +} + +/// Build the tutorial. +fn tutorial_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = markdown_page(resolver, "/docs/", "tutorial/welcome.md"); + page.children = DOCS + .get_dir("tutorial") + .unwrap() + .files() + .filter(|file| file.path() != Path::new("tutorial/welcome.md")) + .map(|file| markdown_page(resolver, "/docs/tutorial/", file.path())) + .collect(); + page +} + +/// Build the reference. +fn reference_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = markdown_page(resolver, "/docs/", "reference/welcome.md"); + page.children = vec![ + markdown_page(resolver, "/docs/reference/", "reference/syntax.md") + .with_part("Language"), + markdown_page(resolver, "/docs/reference/", "reference/styling.md"), + markdown_page(resolver, "/docs/reference/", "reference/scripting.md"), + types_page(resolver, "/docs/reference/"), + category_page(resolver, "text").with_part("Content"), + category_page(resolver, "math"), + category_page(resolver, "layout"), + category_page(resolver, "visualize"), + category_page(resolver, "meta"), + category_page(resolver, "symbols"), + category_page(resolver, "foundations").with_part("Compute"), + category_page(resolver, "calculate"), + category_page(resolver, "construct"), + category_page(resolver, "data-loading"), + ]; + page +} + +/// Build the guides section. +fn guides_pages(resolver: &dyn Resolver) -> PageModel { + let mut page = markdown_page(resolver, "/docs/", "guides/welcome.md"); + page.children = + vec![markdown_page(resolver, "/docs/guides/", "guides/guide-for-latex-users.md")]; + page +} + +/// Build the packages section. +fn packages_page(resolver: &dyn Resolver) -> PageModel { + PageModel { + route: "/docs/packages/".into(), + title: "Packages".into(), + description: "Packages for Typst.".into(), + part: None, + outline: vec![], + body: BodyModel::Packages(Html::markdown(resolver, details("packages"))), + children: vec![], + } +} + +/// Create a page from a markdown file. +#[track_caller] +fn markdown_page( + resolver: &dyn Resolver, + parent: &str, + path: impl AsRef<Path>, +) -> PageModel { + assert!(parent.starts_with('/') && parent.ends_with('/')); + let md = DOCS.get_file(path).unwrap().contents_utf8().unwrap(); + let html = Html::markdown(resolver, md); + let title = html.title().expect("chapter lacks a title").to_string(); + PageModel { + route: format!("{parent}{}/", urlify(&title)), + title, + description: html.description().unwrap(), + part: None, + outline: html.outline(), + body: BodyModel::Html(html), + children: vec![], + } +} + +/// Details about a category. +#[derive(Debug, Serialize)] +pub struct CategoryModel { + pub name: String, + pub details: Html, + pub kind: &'static str, + pub items: Vec<CategoryItem>, +} + +/// Details about a category item. +#[derive(Debug, Serialize)] +pub struct CategoryItem { + pub name: String, + pub route: String, + pub oneliner: String, + pub code: bool, +} + +/// Create a page for a category. +#[track_caller] +fn category_page(resolver: &dyn Resolver, category: &str) -> PageModel { + let route = format!("/docs/reference/{category}/"); + let mut children = vec![]; + let mut items = vec![]; + + let focus = match category { + "math" => &LIBRARY.math, + "calculate" => module(&LIBRARY.global, "calc").unwrap(), + _ => &LIBRARY.global, + }; + + let parents: &[&str] = match category { + "math" => &[], + "calculate" => &["calc"], + _ => &[], + }; + + let grouped = match category { + "math" => GROUPS.as_slice(), + _ => &[], + }; + + // Add functions. + for (_, value) in focus.scope().iter() { + let Value::Func(func) = value else { continue }; + let Some(info) = func.info() else { continue }; + if info.category != category { + continue; + } + + // Skip grouped functions. + if grouped + .iter() + .flat_map(|group| &group.functions) + .any(|f| f == info.name) + { + continue; + } + + let subpage = function_page(resolver, &route, func, info, parents); + items.push(CategoryItem { + name: info.name.into(), + route: subpage.route.clone(), + oneliner: oneliner(info.docs).into(), + code: true, + }); + children.push(subpage); + } + + // Add grouped functions. + for group in grouped { + let mut functions = vec![]; + let mut outline = vec![OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }]; + + for name in &group.functions { + let value = focus.get(name).unwrap(); + let Value::Func(func) = value else { panic!("not a function") }; + let info = func.info().unwrap(); + let func = func_model(resolver, func, info, &[], info.name); + let id = urlify(&func.path.join("-")); + let children = func_outline(&func, &id, false); + outline.push(OutlineItem { id, name: func.display.into(), children }); + functions.push(func); + } + + let route = format!("{}{}/", route, group.name); + items.push(CategoryItem { + name: group.name.clone(), + route: route.clone(), + oneliner: oneliner(&group.description).into(), + code: false, + }); + + children.push(PageModel { + route, + title: group.display.clone(), + description: format!("Documentation for {} group of functions.", group.name), + part: None, + outline, + body: BodyModel::Funcs(FuncsModel { + name: group.name.clone(), + display: group.display.clone(), + details: Html::markdown(resolver, &group.description), + functions, + }), + children: vec![], + }); + } + + children.sort_by_cached_key(|child| child.title.clone()); + items.sort_by_cached_key(|item| item.name.clone()); + + // Add symbol pages. These are ordered manually. + if category == "symbols" { + for module in ["sym", "emoji"] { + let subpage = symbol_page(resolver, &route, module); + items.push(CategoryItem { + name: module.into(), + route: subpage.route.clone(), + oneliner: oneliner(details(module)).into(), + code: true, + }); + children.push(subpage); + } + } + + let name = category.to_title_case(); + let kind = match category { + "symbols" => "Modules", + _ => "Functions", + }; + + PageModel { + route, + title: name.clone(), + description: format!("Documentation for functions related to {name} in Typst."), + part: None, + outline: category_outline(kind), + body: BodyModel::Category(CategoryModel { + name, + details: Html::markdown(resolver, details(category)), + kind, + items, + }), + children, + } +} + +/// Produce an outline for a category page. +fn category_outline(kind: &str) -> Vec<OutlineItem> { + vec![ + OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }, + OutlineItem { + id: urlify(kind), + name: kind.into(), + children: vec![], + }, + ] +} + +/// Details about a function. +#[derive(Debug, Serialize)] +pub struct FuncModel { + pub path: Vec<&'static str>, + pub display: &'static str, + pub keywords: Option<&'static str>, + pub oneliner: &'static str, + pub element: bool, + pub details: Html, + pub params: Vec<ParamModel>, + pub returns: Vec<&'static str>, + pub methods: Vec<MethodModel>, + pub scope: Vec<Self>, +} + +/// Details about a group of functions. +#[derive(Debug, Serialize)] +pub struct FuncsModel { + pub name: String, + pub display: String, + pub details: Html, + pub functions: Vec<FuncModel>, +} + +/// Create a page for a function. +fn function_page( + resolver: &dyn Resolver, + parent: &str, + func: &Func, + info: &FuncInfo, + parents: &[&'static str], +) -> PageModel { + let model = func_model(resolver, func, info, parents, ""); + PageModel { + route: format!("{parent}{}/", urlify(info.name)), + title: info.display.to_string(), + description: format!("Documentation for the `{}` function.", info.name), + part: None, + outline: func_outline(&model, "", true), + body: BodyModel::Func(model), + children: vec![], + } +} + +/// Produce a function's model. +fn func_model( + resolver: &dyn Resolver, + func: &Func, + info: &FuncInfo, + parents: &[&'static str], + id_base: &str, +) -> FuncModel { + let mut s = unscanny::Scanner::new(info.docs); + let docs = s.eat_until("\n## Methods").trim(); + + let mut path = parents.to_vec(); + let mut name = info.name; + for parent in parents.iter().rev() { + name = name + .strip_prefix(parent) + .or(name.strip_prefix(parent.strip_suffix('s').unwrap_or(parent))) + .unwrap_or(name) + .trim_matches('-'); + } + path.push(name); + + let scope = info + .scope + .iter() + .filter_map(|(_, value)| { + let Value::Func(func) = value else { return None }; + let info = func.info().unwrap(); + Some(func_model(resolver, func, info, &path, id_base)) + }) + .collect(); + + let mut returns = vec![]; + casts(resolver, &mut returns, &mut vec![], &info.returns); + returns.sort_by_key(|ty| type_index(ty)); + if returns == ["none"] { + returns.clear(); + } + + FuncModel { + path, + display: info.display, + keywords: info.keywords, + oneliner: oneliner(docs), + element: func.element().is_some(), + details: Html::markdown_with_id_base(resolver, docs, id_base), + params: info.params.iter().map(|param| param_model(resolver, param)).collect(), + returns, + methods: method_models(resolver, info.docs), + scope, + } +} + +/// Produce an outline for a function page. +fn func_outline(model: &FuncModel, base: &str, summary: bool) -> Vec<OutlineItem> { + let mut outline = vec![]; + + if summary { + outline.push(OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }); + } + + outline.extend(model.details.outline()); + + if !model.params.is_empty() { + let join = if base.is_empty() { "" } else { "-" }; + outline.push(OutlineItem { + id: format!("{base}{join}parameters"), + name: "Parameters".into(), + children: model + .params + .iter() + .map(|param| OutlineItem { + id: format!("{base}{join}parameters-{}", urlify(param.name)), + name: param.name.into(), + children: vec![], + }) + .collect(), + }); + } + + for func in &model.scope { + let id = urlify(&func.path.join("-")); + let children = func_outline(func, &id, false); + outline.push(OutlineItem { id, name: func.display.into(), children }) + } + + outline.extend(methods_outline(&model.methods)); + outline +} + +/// Details about a function parameter. +#[derive(Debug, Serialize)] +pub struct ParamModel { + pub name: &'static str, + pub details: Html, + pub example: Option<Html>, + pub types: Vec<&'static str>, + pub strings: Vec<StrParam>, + pub default: Option<Html>, + 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: String, + pub details: Html, +} + +/// Produce a parameter's model. +fn param_model(resolver: &dyn Resolver, info: &ParamInfo) -> ParamModel { + let mut types = vec![]; + let mut strings = vec![]; + casts(resolver, &mut types, &mut strings, &info.cast); + if !strings.is_empty() && !types.contains(&"string") { + types.push("string"); + } + types.sort_by_key(|ty| type_index(ty)); + + let mut details = info.docs; + let mut example = None; + if let Some(mut i) = info.docs.find("```example") { + while info.docs[..i].ends_with('`') { + i -= 1; + } + details = &info.docs[..i]; + example = Some(&info.docs[i..]); + } + + ParamModel { + name: info.name, + details: Html::markdown(resolver, details), + example: example.map(|md| Html::markdown(resolver, md)), + types, + strings, + default: info.default.map(|default| { + let node = typst::syntax::parse_code(&default().repr()); + Html::new(typst::ide::highlight_html(&node)) + }), + positional: info.positional, + named: info.named, + required: info.required, + variadic: info.variadic, + settable: info.settable, + } +} + +/// Process cast information into types and strings. +fn casts( + resolver: &dyn Resolver, + types: &mut Vec<&'static str>, + strings: &mut Vec<StrParam>, + info: &CastInfo, +) { + match info { + CastInfo::Any => types.push("any"), + CastInfo::Value(Value::Str(string), docs) => strings.push(StrParam { + string: string.to_string(), + details: Html::markdown(resolver, docs), + }), + CastInfo::Value(..) => {} + CastInfo::Type(ty) => types.push(ty), + CastInfo::Union(options) => { + for option in options { + casts(resolver, types, strings, option); + } + } + } +} + +/// A collection of symbols. +#[derive(Debug, Serialize)] +pub struct TypeModel { + pub name: String, + pub oneliner: &'static str, + pub details: Html, + pub methods: Vec<MethodModel>, +} + +/// Details about a built-in method on a type. +#[derive(Debug, Serialize)] +pub struct MethodModel { + pub name: &'static str, + pub details: Html, + pub params: Vec<ParamModel>, + pub returns: Vec<&'static str>, +} + +/// Create a page for the types. +fn types_page(resolver: &dyn Resolver, parent: &str) -> PageModel { + let route = format!("{parent}types/"); + let mut children = vec![]; + let mut items = vec![]; + + for model in type_models(resolver) { + let route = format!("{route}{}/", urlify(&model.name)); + items.push(CategoryItem { + name: model.name.clone(), + route: route.clone(), + oneliner: model.oneliner.into(), + code: true, + }); + children.push(PageModel { + route, + title: model.name.to_title_case(), + description: format!("Documentation for the `{}` type.", model.name), + part: None, + outline: type_outline(&model), + body: BodyModel::Type(model), + children: vec![], + }); + } + + PageModel { + route, + title: "Types".into(), + description: "Documentation for Typst's built-in types.".into(), + part: None, + outline: category_outline("Types"), + body: BodyModel::Category(CategoryModel { + name: "Types".into(), + details: Html::markdown(resolver, details("types")), + kind: "Types", + items, + }), + children, + } +} + +/// Produce the types' models. +fn type_models(resolver: &dyn Resolver) -> Vec<TypeModel> { + let file = DOCS.get_file("reference/types.md").unwrap(); + let text = file.contents_utf8().unwrap(); + + let mut s = unscanny::Scanner::new(text); + let mut types = vec![]; + + while s.eat_if("# ") { + let part = s.eat_until("\n# "); + types.push(type_model(resolver, part)); + s.eat_if('\n'); + } + + types +} + +/// Produce a type's model. +fn type_model(resolver: &dyn Resolver, part: &'static str) -> TypeModel { + let mut s = unscanny::Scanner::new(part); + let display = s.eat_until('\n').trim(); + let docs = s.eat_until("\n## Methods").trim(); + TypeModel { + name: display.to_lowercase(), + oneliner: oneliner(docs), + details: Html::markdown(resolver, docs), + methods: method_models(resolver, part), + } +} + +/// Produce multiple methods' models. +fn method_models(resolver: &dyn Resolver, docs: &'static str) -> Vec<MethodModel> { + let mut s = unscanny::Scanner::new(docs); + s.eat_until("\n## Methods"); + s.eat_whitespace(); + + let mut methods = vec![]; + if s.eat_if("## Methods") { + s.eat_until("\n### "); + while s.eat_if("\n### ") { + methods.push(method_model(resolver, s.eat_until("\n### "))); + } + } + + methods +} + +/// Produce a method's model. +fn method_model(resolver: &dyn Resolver, part: &'static str) -> MethodModel { + let mut s = unscanny::Scanner::new(part); + let mut params = vec![]; + let mut returns = vec![]; + + let name = s.eat_until('(').trim(); + s.expect("()"); + let docs = s.eat_until("\n- ").trim(); + + while s.eat_if("\n- ") { + let name = s.eat_until(':'); + s.expect(": "); + let types: Vec<_> = + s.eat_until(['(', '\n']).split(" or ").map(str::trim).collect(); + if !types.iter().all(|ty| type_index(ty) != usize::MAX) { + panic!( + "unknown type in method {} parameter {}", + name, + types.iter().find(|ty| type_index(ty) == usize::MAX).unwrap() + ) + } + + if name == "returns" { + returns = types; + continue; + } + + s.expect('('); + + let mut named = false; + let mut positional = false; + let mut required = false; + let mut variadic = false; + for part in s.eat_until(')').split(',').map(str::trim) { + match part { + "named" => named = true, + "positional" => positional = true, + "required" => required = true, + "variadic" => variadic = true, + _ => panic!("unknown parameter flag {:?}", part), + } + } + + s.expect(')'); + + params.push(ParamModel { + name, + details: Html::markdown(resolver, s.eat_until("\n- ").trim()), + example: None, + types, + strings: vec![], + default: None, + positional, + named, + required, + variadic, + settable: false, + }); + } + + MethodModel { + name, + details: Html::markdown(resolver, docs), + params, + returns, + } +} + +/// Produce an outline for a type page. +fn type_outline(model: &TypeModel) -> Vec<OutlineItem> { + let mut outline = vec![OutlineItem { + id: "summary".into(), + name: "Summary".into(), + children: vec![], + }]; + + outline.extend(methods_outline(&model.methods)); + outline +} + +/// Produce an outline for a type's method. +fn methods_outline(methods: &[MethodModel]) -> Option<OutlineItem> { + (!methods.is_empty()).then(|| OutlineItem { + id: "methods".into(), + name: "Methods".into(), + children: methods.iter().map(method_outline).collect(), + }) +} + +/// Produce an outline for a type's method. +fn method_outline(model: &MethodModel) -> OutlineItem { + OutlineItem { + id: format!("methods-{}", urlify(model.name)), + name: model.name.into(), + children: model + .params + .iter() + .map(|param| OutlineItem { + id: format!( + "methods-{}-parameters-{}", + urlify(model.name), + urlify(param.name) + ), + name: param.name.into(), + children: vec![], + }) + .collect(), + } +} + +/// A collection of symbols. +#[derive(Debug, Serialize)] +pub struct SymbolsModel { + pub name: &'static str, + pub details: Html, + pub list: Vec<SymbolModel>, +} + +/// Details about a symbol. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SymbolModel { + pub name: String, + pub shorthand: Option<&'static str>, + pub codepoint: u32, + pub accent: bool, + pub unicode_name: Option<String>, + pub alternates: Vec<String>, +} + +/// Create a page for symbols. +fn symbol_page(resolver: &dyn Resolver, parent: &str, name: &str) -> PageModel { + let module = module(&LIBRARY.global, name).unwrap(); + + let mut list = vec![]; + for (name, value) in module.scope().iter() { + let Value::Symbol(symbol) = value else { continue }; + let complete = |variant: &str| { + if variant.is_empty() { + name.into() + } else { + format!("{}.{}", name, variant) + } + }; + + for (variant, c) in symbol.variants() { + list.push(SymbolModel { + name: complete(variant), + shorthand: typst::syntax::ast::Shorthand::LIST + .iter() + .copied() + .find(|&(_, x)| x == c) + .map(|(s, _)| s), + codepoint: c as u32, + accent: typst::eval::Symbol::combining_accent(c).is_some(), + unicode_name: unicode_names2::name(c) + .map(|s| s.to_string().to_title_case()), + alternates: symbol + .variants() + .filter(|(other, _)| other != &variant) + .map(|(other, _)| complete(other)) + .collect(), + }); + } + } + + let title = match name { + "sym" => "General", + "emoji" => "Emoji", + _ => unreachable!(), + }; + + PageModel { + route: format!("{parent}{name}/"), + title: title.into(), + description: format!("Documentation for the `{name}` module."), + part: None, + outline: vec![], + body: BodyModel::Symbols(SymbolsModel { + name: title, + details: Html::markdown(resolver, details(name)), + list, + }), + children: vec![], + } +} + +/// Data about a collection of functions. +#[derive(Debug, Deserialize)] +struct GroupData { + name: String, + display: String, + functions: Vec<String>, + description: String, +} + +/// Extract a module from another module. +#[track_caller] +fn module<'a>(parent: &'a Module, name: &str) -> Result<&'a Module, String> { + match parent.scope().get(name) { + Some(Value::Module(module)) => Ok(module), + _ => Err(format!("module doesn't contain module `{name}`")), + } +} + +/// Load YAML from a path. +#[track_caller] +fn yaml<T: DeserializeOwned>(path: &str) -> T { + let file = DOCS.get_file(path).unwrap(); + yaml::from_slice(file.contents()).unwrap() +} + +/// Load details for an identifying key. +#[track_caller] +fn details(key: &str) -> &str { + DETAILS + .get(&yaml::Value::String(key.into())) + .and_then(|value| value.as_str()) + .unwrap_or_else(|| panic!("missing details for {key}")) +} + +/// Turn a title into an URL fragment. +pub fn urlify(title: &str) -> String { + 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", + "boolean", + "integer", + "float", + "length", + "angle", + "ratio", + "relative length", + "fraction", + "color", + "datetime", + "string", + "regex", + "label", + "content", + "array", + "dictionary", + "function", + "arguments", + "location", + "dir", + "alignment", + "2d alignment", + "selector", + "stroke", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_docs() { + provide(&TestResolver); + } + + struct TestResolver; + + impl Resolver for TestResolver { + fn link(&self, _: &str) -> Option<String> { + None + } + + fn example(&self, _: Html, _: &[Frame]) -> Html { + Html::new(String::new()) + } + + fn image(&self, _: &str, _: &[u8]) -> String { + String::new() + } + + fn commits(&self, _: &str, _: &str) -> Vec<Commit> { + vec![] + } + } +} |
