//! 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 once_cell::sync::Lazy; use serde::Deserialize; use serde_yaml as yaml; use typst::diag::{bail, StrResult}; use typst::foundations::AutoValue; use typst::foundations::Bytes; use typst::foundations::NoneValue; 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(); let scope = lib.global.scope_mut(); // Add those types, so that they show up in the docs. scope.category(FOUNDATIONS); scope.define_type::(); scope.define_type::(); // Adjust the default look. 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. If this returns `None`, the system will try to /// resolve the link 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")), md_page(resolver, &base, load!("guides/tables.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 values and types. 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().is_some_and(|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(), 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 { "/" } } }