From d4d702017c41b27879f6eac9280163cf7f3d5e8d Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 6 Feb 2023 11:52:31 +0100 Subject: Documentation provider --- docs/src/lib.rs | 744 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 744 insertions(+) create mode 100644 docs/src/lib.rs (limited to 'docs/src/lib.rs') diff --git a/docs/src/lib.rs b/docs/src/lib.rs new file mode 100644 index 00000000..76f25250 --- /dev/null +++ b/docs/src/lib.rs @@ -0,0 +1,744 @@ +//! Documentation provider for Typst. + +mod html; + +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::font::{Font, FontBook}; +use typst::geom::{Abs, Sides, Smart}; +use typst::model::{CastInfo, Func, FuncInfo, Library, Module, ParamInfo, Value}; +use typst_library::layout::PageNode; +use unscanny::Scanner; + +static SRC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src"); +static FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../assets/files"); +static IMAGES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../assets/images"); +static DETAILS: Lazy = Lazy::new(|| yaml("reference/details.yml")); +static GROUPS: Lazy> = Lazy::new(|| yaml("reference/groups.yml")); + +static FONTS: Lazy<(Prehashed, Vec)> = 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> = Lazy::new(|| { + let mut lib = typst_library::build(); + lib.styles.set(PageNode::WIDTH, Smart::Custom(Abs::pt(240.0).into())); + lib.styles.set(PageNode::HEIGHT, Smart::Auto); + lib.styles + .set(PageNode::MARGIN, Sides::splat(Some(Smart::Custom(Abs::pt(15.0).into())))); + typst::model::set_lang_items(lib.items.clone()); + Prehashed::new(lib) +}); + +/// Build documentation pages. +pub fn provide(resolver: &dyn Resolver) -> Vec { + vec![ + markdown_page(resolver, "/docs/", "general/overview.md").with_route("/docs/"), + tutorial_page(resolver), + reference_page(resolver), + markdown_page(resolver, "/docs/", "general/changelog.md"), + markdown_page(resolver, "/docs/", "general/community.md"), + ] +} + +/// Resolve consumer dependencies. +pub trait Resolver { + /// 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, frame: Frame) -> Html; +} + +/// 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 body: BodyModel, + pub children: Vec, +} + +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 } + } +} + +/// 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), +} + +/// Build the tutorial. +fn tutorial_page(resolver: &dyn Resolver) -> PageModel { + let mut page = markdown_page(resolver, "/docs/", "tutorial/welcome.md"); + page.children = SRC + .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_page(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, "basics").with_part("Content"), + 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, "foundations").with_part("Compute"), + category_page(resolver, "calculate"), + category_page(resolver, "construct"), + category_page(resolver, "data-loading"), + category_page(resolver, "utility"), + ]; + page +} + +/// 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 = SRC.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, + 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, +} + +/// 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"), + _ => &LIBRARY.global, + }; + + 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(|merge| &merge.functions) + .any(|f| f == info.name) + { + continue; + } + + let subpage = function_page(resolver, &route, func, info); + 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![]; + 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(); + functions.push(func_model(resolver, func, info)); + } + + 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.title.clone(), + description: format!("Documentation for {} group of functions.", group.name), + part: None, + body: BodyModel::Funcs(FuncsModel { + name: group.name.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(); + PageModel { + route, + title: name.clone(), + description: format!("Documentation for functions related to {name} in Typst."), + part: None, + body: BodyModel::Category(CategoryModel { + name, + details: Html::markdown(resolver, details(category)), + kind: match category { + "symbols" => "Modules", + _ => "Functions", + }, + items, + }), + children, + } +} + +/// Details about a function. +#[derive(Debug, Serialize)] +pub struct FuncModel { + pub name: &'static str, + pub oneliner: &'static str, + pub details: Html, + pub showable: bool, + pub params: Vec, + pub returns: Vec<&'static str>, +} + +/// Details about a group of functions. +#[derive(Debug, Serialize)] +pub struct FuncsModel { + pub name: String, + pub details: Html, + pub functions: Vec, +} + +/// Create a page for a function. +fn function_page( + resolver: &dyn Resolver, + parent: &str, + func: &Func, + info: &FuncInfo, +) -> PageModel { + PageModel { + route: format!("{parent}{}/", urlify(info.name)), + title: info.display.to_string(), + description: format!("Documentation for the `{}` function.", info.name), + part: None, + body: BodyModel::Func(func_model(resolver, func, info)), + children: vec![], + } +} + +/// Produce a function's model. +fn func_model(resolver: &dyn Resolver, func: &Func, info: &FuncInfo) -> FuncModel { + FuncModel { + name: info.name.into(), + oneliner: oneliner(info.docs), + details: Html::markdown(resolver, info.docs), + showable: func.select(None).is_ok() && info.category != "math", + params: info.params.iter().map(|param| param_model(resolver, param)).collect(), + returns: info.returns.clone(), + } +} + +/// 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 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, + 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, + 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, +} + +/// 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, + 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}{}/", 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, + body: BodyModel::Type(model), + children: vec![], + }); + } + + PageModel { + route, + title: "Types".into(), + description: "Documentation for Typst's built-in types.".into(), + part: None, + 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 { + let file = SRC.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(); + + 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### "))); + } + } + + TypeModel { + name: display.to_lowercase(), + oneliner: oneliner(docs), + details: Html::markdown(resolver, docs), + 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![], + positional, + named, + required, + variadic, + settable: false, + }); + } + + MethodModel { + name, + details: Html::markdown(resolver, docs), + params, + returns, + } +} + +/// A collection of symbols. +#[derive(Debug, Serialize)] +pub struct SymbolsModel { + pub name: &'static str, + pub details: Html, + pub list: Vec, +} + +/// 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, + pub alternates: Vec, +} + +/// Create a page for symbols. +fn symbol_page(resolver: &dyn Resolver, parent: &str, name: &str) -> PageModel { + let module = &module(&LIBRARY.global, name); + + 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::model::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, + 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, + title: String, + functions: Vec, + description: String, +} + +/// Extract a module from another module. +#[track_caller] +fn module<'a>(parent: &'a Module, name: &str) -> &'a Module { + match parent.scope().get(name) { + Some(Value::Module(module)) => module, + _ => panic!("module doesn't contain module `{name}`"), + } +} + +/// Load YAML from a path. +#[track_caller] +fn yaml(path: &str) -> T { + let file = SRC.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. +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().into() +} + +/// 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", + "string", + "regex", + "label", + "content", + "array", + "dictionary", + "function", + "arguments", + "dir", + "alignment", + "2d alignment", + "selector", + "stroke", +]; -- cgit v1.2.3