//! Documentation provider for Typst. mod contribs; mod html; mod link; mod model; pub use contribs::{contributors, Author, Commit}; pub use html::Html; pub use 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::doc::Frame; use typst::eval::{ CastInfo, Func, Library, Module, ParamInfo, Repr, Scope, Smart, Type, Value, }; use typst::font::{Font, FontBook}; use typst::geom::Abs; use typst_library::layout::{Margin, PageElem}; static DOCS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../docs"); static FILE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/files"); static FONT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../assets/fonts"); static CATEGORIES: Lazy = Lazy::new(|| yaml("reference/categories.yml")); static GROUPS: Lazy> = Lazy::new(|| yaml("reference/groups.yml")); static LIBRARY: Lazy> = 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) }); static FONTS: Lazy<(Prehashed, Vec)> = Lazy::new(|| { let fonts: Vec<_> = FONT_DIR .files() .flat_map(|file| Font::iter(file.contents().into())) .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, "/docs/", "overview.md").with_route("/docs/"), tutorial_pages(resolver), reference_pages(resolver), guide_pages(resolver), packages_page(resolver), markdown_page(resolver, "/docs/", "changelog.md"), markdown_page(resolver, "/docs/", "roadmap.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; /// 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, frames: &[Frame]) -> Html; /// Determine the commits between two tags. fn commits(&self, from: &str, to: &str) -> Vec; } /// 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, "/docs/", "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, "/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"), category_page(resolver, "foundations").with_part("Library"), category_page(resolver, "text"), category_page(resolver, "math"), category_page(resolver, "layout"), category_page(resolver, "visualize"), category_page(resolver, "meta"), category_page(resolver, "symbols"), category_page(resolver, "data-loading"), ]; page } /// Build the guides section. fn guide_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"), markdown_page(resolver, "/docs/guides/", "guides/page-setup.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, category_details("packages"), Some(1), )), children: vec![], } } /// Create a page for a category. #[track_caller] fn category_page(resolver: &dyn Resolver, category: &str) -> PageModel { let route = eco_format!("/docs/reference/{category}/"); let mut children = vec![]; let mut items = vec![]; let (module, path): (&Module, &[&str]) = match category { "math" => (&LIBRARY.math, &["math"]), _ => (&LIBRARY.global, &[]), }; // Add groups. for mut group in GROUPS.iter().filter(|g| g.category == category).cloned() { let mut focus = module; if matches!(group.name.as_str(), "calc" | "sys") { focus = get_module(focus, &group.name).unwrap(); group.functions = focus .scope() .iter() .filter(|(_, v)| matches!(v, Value::Func(_))) .map(|(k, _)| k.clone()) .collect(); } let (child, item) = group_page(resolver, &route, &group, focus.scope()); children.push(child); items.push(item); } // 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.functions).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); } _ => {} } } 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. let mut shorthands = None; if category == "symbols" { let mut markup = vec![]; let mut math = vec![]; for module in ["sym", "emoji"] { let subpage = symbols_page(resolver, &route, module); 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: module.into(), route: subpage.route.clone(), oneliner: oneliner(category_details(module)).into(), code: true, }); children.push(subpage); } shorthands = Some(ShorthandsModel { markup, math }); } let name: EcoString = category.to_title_case().into(); let details = Html::markdown(resolver, category_details(category), Some(1)); let mut outline = vec![OutlineItem::from_name("Summary")]; outline.extend(details.outline()); outline.push(OutlineItem::from_name("Definitions")); PageModel { route, title: name.clone(), description: eco_format!( "Documentation for functions related to {name} in Typst." ), part: None, outline, body: BodyModel::Category(CategoryModel { name, 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(), 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, scope: &Scope, ) -> (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.description, Some(1)); outline.extend(details.outline()); let mut outline_items = vec![]; for name in &group.functions { let value = 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.display.clone(), description: eco_format!("Documentation for the {} functions.", group.name), part: None, outline, body: BodyModel::Group(GroupModel { name: group.name.clone(), title: group.display.clone(), details, functions, }), children: vec![], }; let item = CategoryItem { name: group.name.clone(), route: model.route.clone(), oneliner: oneliner(&group.description).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, name: &str) -> PageModel { let module = get_module(&LIBRARY.global, name).unwrap(); let title = match name { "sym" => "General", "emoji" => "Emoji", _ => unreachable!(), }; let model = symbols_model(resolver, name, title, module.scope()); PageModel { route: eco_format!("{parent}{name}/"), title: title.into(), description: eco_format!("Documentation for the `{name}` module."), part: None, outline: vec![], body: BodyModel::Symbols(model), children: vec![], } } /// Produce a symbol list's model. fn symbols_model( resolver: &dyn Resolver, name: &str, title: &'static str, scope: &Scope, ) -> SymbolsModel { let mut list = vec![]; for (name, value) in 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::eval::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: title, details: Html::markdown(resolver, category_details(name), 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() } /// Load details for an identifying key. #[track_caller] fn category_details(key: &str) -> &str { CATEGORIES .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) -> 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, category: EcoString, display: EcoString, #[serde(default)] path: Vec, #[serde(default)] functions: Vec, description: EcoString, } #[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, _: &[Frame]) -> Html { Html::new(String::new()) } fn image(&self, _: &str, _: &[u8]) -> String { String::new() } fn commits(&self, _: &str, _: &str) -> Vec { vec![] } } }