diff options
Diffstat (limited to 'crates/typst-docs/src')
| -rw-r--r-- | crates/typst-docs/src/html.rs | 238 | ||||
| -rw-r--r-- | crates/typst-docs/src/lib.rs | 1010 | ||||
| -rw-r--r-- | crates/typst-docs/src/link.rs | 102 | ||||
| -rw-r--r-- | crates/typst-docs/src/model.rs | 170 |
4 files changed, 749 insertions, 771 deletions
diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs index bde69d89..c64649e3 100644 --- a/crates/typst-docs/src/html.rs +++ b/crates/typst-docs/src/html.rs @@ -1,17 +1,22 @@ +use std::fmt::{self, Debug, Formatter}; use std::ops::Range; use comemo::Prehashed; +use ecow::EcoString; +use heck::{ToKebabCase, ToTitleCase}; use pulldown_cmark as md; +use serde::{Deserialize, Serialize}; use typed_arena::Arena; -use typst::diag::FileResult; -use typst::eval::{Bytes, Datetime, Tracer}; +use typst::diag::{FileResult, StrResult}; +use typst::eval::{Bytes, Datetime, Library, Tracer}; use typst::font::{Font, FontBook}; -use typst::geom::{Point, Size}; +use typst::geom::{Abs, Point, Size}; use typst::syntax::{FileId, Source, VirtualPath}; use typst::World; +use unscanny::Scanner; use yaml_front_matter::YamlFrontMatter; -use super::*; +use super::{contributors, OutlineItem, Resolver, FILE_DIR, FONTS, LIBRARY}; /// HTML documentation. #[derive(Serialize)] @@ -21,7 +26,7 @@ pub struct Html { #[serde(skip)] md: String, #[serde(skip)] - description: Option<String>, + description: Option<EcoString>, #[serde(skip)] outline: Vec<OutlineItem>, } @@ -39,18 +44,7 @@ impl Html { /// 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 { + pub fn markdown(resolver: &dyn Resolver, md: &str, nesting: Option<usize>) -> Self { let mut text = md; let mut description = None; let document = YamlFrontMatter::parse::<Metadata>(md); @@ -62,9 +56,18 @@ impl Html { 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 handler = Handler::new(text, resolver, nesting, &ids); + let mut events = md::Parser::new_ext(text, options).peekable(); + let iter = std::iter::from_fn(|| loop { + let mut event = events.next()?; + handler.peeked = events.peek().and_then(|event| match event { + md::Event::Text(text) => Some(text.clone()), + _ => None, + }); + if handler.handle(&mut event) { + return Some(event); + } + }); let mut raw = String::new(); md::html::push_html(&mut raw, iter); @@ -93,7 +96,11 @@ impl 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>")) + s.eat_if("<h1").then(|| { + s.eat_until('>'); + s.eat_if('>'); + s.eat_until("</h1>") + }) } /// The outline of the HTML. @@ -102,7 +109,7 @@ impl Html { } /// The description from the front matter. - pub fn description(&self) -> Option<String> { + pub fn description(&self) -> Option<EcoString> { self.description.clone() } } @@ -116,26 +123,35 @@ impl Debug for Html { /// Front matter metadata. #[derive(Deserialize)] struct Metadata { - description: String, + description: EcoString, } struct Handler<'a> { + text: &'a str, resolver: &'a dyn Resolver, - lang: Option<String>, - code: String, + peeked: Option<md::CowStr<'a>>, + lang: Option<EcoString>, + code: EcoString, outline: Vec<OutlineItem>, - id_base: String, + nesting: Option<usize>, ids: &'a Arena<String>, } impl<'a> Handler<'a> { - fn new(resolver: &'a dyn Resolver, id_base: String, ids: &'a Arena<String>) -> Self { + fn new( + text: &'a str, + resolver: &'a dyn Resolver, + nesting: Option<usize>, + ids: &'a Arena<String>, + ) -> Self { Self { + text, resolver, + peeked: None, lang: None, - code: String::new(), + code: EcoString::new(), outline: vec![], - id_base, + nesting, ids, } } @@ -157,15 +173,13 @@ impl<'a> Handler<'a> { } // Register HTML headings for the outline. - md::Event::Start(md::Tag::Heading(level, Some(id), _)) => { + md::Event::Start(md::Tag::Heading(level, 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); - } + md::Event::End(md::Tag::Heading(level, _, _)) => { + nest_heading(level, self.nesting()); } // Rewrite contributor sections. @@ -185,10 +199,10 @@ impl<'a> Handler<'a> { "unsupported link type: {ty:?}", ); - *dest = self - .handle_link(dest) - .unwrap_or_else(|| panic!("invalid link: {dest}")) - .into(); + *dest = match self.handle_link(dest) { + Ok(link) => link.into(), + Err(err) => panic!("invalid link: {dest} ({err})"), + }; } // Inline raw. @@ -208,7 +222,7 @@ impl<'a> Handler<'a> { // Code blocks. md::Event::Start(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(lang))) => { self.lang = Some(lang.as_ref().into()); - self.code = String::new(); + self.code = EcoString::new(); return false; } md::Event::End(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(_))) => { @@ -232,7 +246,7 @@ impl<'a> Handler<'a> { } fn handle_image(&self, link: &str) -> String { - if let Some(file) = FILES.get_file(link) { + if let Some(file) = FILE_DIR.get_file(link) { self.resolver.image(link, file.contents()) } else if let Some(url) = self.resolver.link(link) { url @@ -241,16 +255,35 @@ impl<'a> Handler<'a> { } } - fn handle_heading(&mut self, id: &mut &'a str, level: &mut md::HeadingLevel) { + fn handle_heading( + &mut self, + id_slot: &mut Option<&'a str>, + level: &mut md::HeadingLevel, + ) { + nest_heading(level, self.nesting()); if *level == md::HeadingLevel::H1 { return; } + let default = self.peeked.as_ref().map(|text| text.to_kebab_case()); + let id: &'a str = match (&id_slot, default) { + (Some(id), default) => { + if Some(*id) == default.as_deref() { + eprintln!("heading id #{id} was specified unnecessarily"); + } + id + } + (None, Some(default)) => self.ids.alloc(default).as_str(), + (None, None) => panic!("missing heading id {}", self.text), + }; + + *id_slot = (!id.is_empty()).then_some(id); + // Special case for things like "v0.3.0". let name = if id.starts_with('v') && id.contains('.') { - id.to_string() + id.into() } else { - id.to_title_case() + id.to_title_case().into() }; let mut children = &mut self.outline; @@ -262,106 +295,22 @@ impl<'a> Handler<'a> { 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![] }); + children.push(OutlineItem { id: id.into(), name, children: vec![] }); } - fn handle_link(&self, link: &str) -> Option<String> { - if link.starts_with('#') || link.starts_with("http") { - return Some(link.into()); + fn handle_link(&self, link: &str) -> StrResult<String> { + if let Some(link) = self.resolver.link(link) { + return Ok(link); } - 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); - } + crate::link::resolve(link) + } - if !route.contains('#') && !route.ends_with('/') { - route.push('/'); + fn nesting(&self) -> usize { + match self.nesting { + Some(nesting) => nesting, + None => panic!("headings are not allowed here:\n{}", self.text), } - - Some(route) } } @@ -466,15 +415,10 @@ fn html_attr_range(html: &str, attr: &str) -> Option<Range<usize>> { } /// 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, - }; +fn nest_heading(level: &mut md::HeadingLevel, nesting: usize) { + *level = ((*level as usize) + nesting) + .try_into() + .unwrap_or(md::HeadingLevel::H6); } /// A world for example compilations. @@ -499,7 +443,7 @@ impl World for DocWorld { fn file(&self, id: FileId) -> FileResult<Bytes> { assert!(id.package().is_none()); - Ok(FILES + Ok(FILE_DIR .get_file(id.vpath().as_rootless_path()) .unwrap_or_else(|| panic!("failed to load {:?}", id.vpath())) .contents() diff --git a/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs index 96ae460c..3018c244 100644 --- a/crates/typst-docs/src/lib.rs +++ b/crates/typst-docs/src/lib.rs @@ -2,49 +2,39 @@ mod contribs; mod html; +mod link; +mod model; pub use contribs::{contributors, Author, Commit}; pub use html::Html; +pub use model::*; -use std::fmt::{self, Debug, Formatter}; 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, Serialize}; +use serde::Deserialize; use serde_yaml as yaml; +use typst::diag::{bail, StrResult}; use typst::doc::Frame; -use typst::eval::{CastInfo, Func, FuncInfo, Library, Module, ParamInfo, Value}; +use typst::eval::{CastInfo, Func, Library, Module, ParamInfo, Scope, Type, 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 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 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 CATEGORIES: Lazy<yaml::Mapping> = Lazy::new(|| yaml("reference/categories.yml")); +static GROUPS: Lazy<Vec<GroupData>> = Lazy::new(|| yaml("reference/groups.yml")); static LIBRARY: Lazy<Prehashed<Library>> = Lazy::new(|| { let mut lib = typst_library::build(); - // Hack for documenting the `mix` function in the color module. - // Will be superseded by proper associated functions. - lib.global - .scope_mut() - .define("mix", typst_library::compute::mix_func()); lib.styles .set(PageElem::set_width(Smart::Custom(Abs::pt(240.0).into()))); lib.styles.set(PageElem::set_height(Smart::Auto)); @@ -55,13 +45,22 @@ static LIBRARY: Lazy<Prehashed<Library>> = Lazy::new(|| { Prehashed::new(lib) }); +static FONTS: Lazy<(Prehashed<FontBook>, Vec<Font>)> = 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<PageModel> { vec![ markdown_page(resolver, "/docs/", "overview.md").with_route("/docs/"), tutorial_pages(resolver), reference_pages(resolver), - guides_pages(resolver), + guide_pages(resolver), packages_page(resolver), markdown_page(resolver, "/docs/", "changelog.md"), markdown_page(resolver, "/docs/", "roadmap.md"), @@ -84,54 +83,32 @@ pub trait Resolver { 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 } +/// 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_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![], } } -/// 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 + page.children = DOCS_DIR .get_dir("tutorial") .unwrap() .files() @@ -149,23 +126,20 @@ fn reference_pages(resolver: &dyn Resolver) -> PageModel { .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, "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, "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 { +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"), @@ -182,257 +156,162 @@ fn packages_page(resolver: &dyn Resolver) -> PageModel { 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), + body: BodyModel::Packages(Html::markdown( + resolver, + category_details("packages"), + Some(1), + )), 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>, - pub markup_shorthands: Option<Vec<SymbolModel>>, - pub math_shorthands: Option<Vec<SymbolModel>>, -} - -/// 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 route = eco_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 (module, path): (&Module, &[&str]) = match category { + "math" => (&LIBRARY.math, &["math"]), + _ => (&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; + // Add groups. + for mut group in GROUPS.iter().filter(|g| g.category == category).cloned() { + let mut focus = module; + if group.name == "calc" { + focus = get_module(focus, "calc").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); + } - // Skip grouped functions. - if grouped - .iter() - .flat_map(|group| &group.functions) - .any(|f| f == info.name) - { + // Add functions. + let scope = module.scope(); + for (name, value) in scope.iter() { + if scope.get_category(name) != Some(category) { 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); - } + if category == "math" { + // Skip grouped functions. + if GROUPS.iter().flat_map(|group| &group.functions).any(|f| f == name) { + continue; + } - // 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); + // Already documented in the text category. + if name == "text" { + continue; + } } - 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![], - }); + 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 markup_shorthands = vec![]; - let mut math_shorthands = vec![]; + let mut shorthands = None; if category == "symbols" { + let mut markup = vec![]; + let mut math = vec![]; for module in ["sym", "emoji"] { - let subpage = symbol_page(resolver, &route, module); + let subpage = symbols_page(resolver, &route, module); let BodyModel::Symbols(model) = &subpage.body else { continue }; let list = &model.list; - markup_shorthands.extend( + markup.extend( list.iter() .filter(|symbol| symbol.markup_shorthand.is_some()) .cloned(), ); - math_shorthands.extend( + math.extend( list.iter().filter(|symbol| symbol.math_shorthand.is_some()).cloned(), ); items.push(CategoryItem { name: module.into(), route: subpage.route.clone(), - oneliner: oneliner(details(module)).into(), + oneliner: oneliner(category_details(module)).into(), code: true, }); children.push(subpage); } + shorthands = Some(ShorthandsModel { markup, math }); } - let name = category.to_title_case(); - let kind = match category { - "symbols" => "Modules", - _ => "Functions", - }; + let name: EcoString = category.to_title_case().into(); PageModel { route, title: name.clone(), - description: format!("Documentation for functions related to {name} in Typst."), + description: eco_format!( + "Documentation for functions related to {name} in Typst." + ), part: None, - outline: category_outline(kind), + outline: category_outline(), body: BodyModel::Category(CategoryModel { name, - details: Html::markdown(resolver, details(category)), - kind, + details: Html::markdown(resolver, category_details(category), Some(1)), items, - markup_shorthands: Some(markup_shorthands), - math_shorthands: Some(math_shorthands), + shorthands, }), 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>, +fn category_outline() -> Vec<OutlineItem> { + vec![OutlineItem::from_name("Summary"), OutlineItem::from_name("Definitions")] } /// Create a page for a function. -fn function_page( +fn func_page( resolver: &dyn Resolver, parent: &str, func: &Func, - info: &FuncInfo, - parents: &[&'static str], + path: &[&str], ) -> PageModel { - let model = func_model(resolver, func, info, parents, ""); + let model = func_model(resolver, func, path, false); + let name = func.name().unwrap(); PageModel { - route: format!("{parent}{}/", urlify(info.name)), - title: info.display.to_string(), - description: format!("Documentation for the `{}` function.", info.name), + 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, "", true), + outline: func_outline(&model, ""), body: BodyModel::Func(model), children: vec![], } @@ -442,143 +321,63 @@ fn function_page( fn func_model( resolver: &dyn Resolver, func: &Func, - info: &FuncInfo, - parents: &[&'static str], - id_base: &str, + path: &[&str], + nested: bool, ) -> 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 name = func.name().unwrap(); + let scope = func.scope().unwrap(); + let docs = func.docs().unwrap(); - 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 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![], &info.returns); + 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, - display: info.display, - keywords: info.keywords, - oneliner: oneliner(docs), + 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_with_id_base(resolver, docs, id_base), - params: info.params.iter().map(|param| param_model(resolver, param)).collect(), + 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, - 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![], - }); + scope: scope_models(resolver, name, scope), } - - 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 (details, example) = split_details_and_example(info.docs); + let mut types = vec![]; let mut strings = vec![]; - casts(resolver, &mut types, &mut strings, &info.cast); + casts(resolver, &mut types, &mut strings, &info.input); 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)), + details: Html::markdown(resolver, details, None), + example: example.map(|md| Html::markdown(resolver, md, None)), types, strings, default: info.default.map(|default| { @@ -593,6 +392,20 @@ fn param_model(resolver: &dyn Resolver, info: &ParamInfo) -> ParamModel { } } +/// 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, @@ -603,11 +416,11 @@ fn casts( 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), + string: string.clone().into(), + details: Html::markdown(resolver, docs, None), }), CastInfo::Value(..) => {} - CastInfo::Type(ty) => types.push(ty), + CastInfo::Type(ty) => types.push(ty.short_name()), CastInfo::Union(options) => { for option in options { casts(resolver, types, strings, option); @@ -616,258 +429,216 @@ fn casts( } } -/// A collection of symbols. -#[derive(Debug, Serialize)] -pub struct TypeModel { - pub name: String, - pub oneliner: &'static str, - pub details: Html, - pub methods: Vec<MethodModel>, +/// Produce models for a function's scope. +fn scope_models(resolver: &dyn Resolver, name: &str, scope: &Scope) -> Vec<FuncModel> { + scope + .iter() + .filter_map(|(_, value)| { + let Value::Func(func) = value else { return None }; + Some(func_model(resolver, func, &[name], true)) + }) + .collect() } -/// 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>, -} +/// Produce an outline for a function page. +fn func_outline(model: &FuncModel, id_base: &str) -> Vec<OutlineItem> { + let mut outline = vec![]; -/// 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![]; + 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(), + }); + } - 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), + 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![], - }); + })); } - 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, - markup_shorthands: None, - math_shorthands: None, - }), - children, - } + outline } -/// 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'); +/// Produce an outline for a function scope. +fn scope_outline(scope: &[FuncModel]) -> Option<OutlineItem> { + if scope.is_empty() { + return None; } - 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), - } + 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(), + }) } -/// 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### "))); - } +/// 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); } - 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() - ) - } + outline.push(OutlineItem { + id: "functions".into(), + name: "Functions".into(), + children: outline_items, + }); - if name == "returns" { - returns = types; - continue; - } + 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![], + }; - 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), - } - } + let item = CategoryItem { + name: group.name.clone(), + route: model.route.clone(), + oneliner: oneliner(&group.description).into(), + code: false, + }; - s.expect(')'); + (model, item) +} - 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, - }); +/// 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![], } +} - MethodModel { - name, - details: Html::markdown(resolver, docs), - params, - returns, +/// 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<OutlineItem> { - let mut outline = vec![OutlineItem { - id: "summary".into(), - name: "Summary".into(), - children: 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(methods_outline(&model.methods)); + outline.extend(scope_outline(&model.scope)); 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(), - }) -} +/// 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!(), + }; -/// 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(), + 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![], } } -/// 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, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SymbolModel { - pub name: String, - pub markup_shorthand: Option<&'static str>, - pub math_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(); - +/// 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 module.scope().iter() { + for (name, value) in scope.iter() { let Value::Symbol(symbol) = value else { continue }; let complete = |variant: &str| { if variant.is_empty() { - name.into() + name.clone() } else { - format!("{}.{}", name, variant) + eco_format!("{}.{}", name, variant) } }; @@ -883,7 +654,7 @@ fn symbol_page(resolver: &dyn Resolver, parent: &str, name: &str) -> PageModel { 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()), + .map(|s| s.to_string().to_title_case().into()), alternates: symbol .variants() .filter(|(other, _)| other != &variant) @@ -893,63 +664,40 @@ fn symbol_page(resolver: &dyn Resolver, parent: &str, name: &str) -> PageModel { } } - 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![], + SymbolsModel { + name: title, + details: Html::markdown(resolver, category_details(name), Some(1)), + list, } } -/// 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> { +fn get_module<'a>(parent: &'a Module, name: &str) -> StrResult<&'a Module> { match parent.scope().get(name) { Some(Value::Module(module)) => Ok(module), - _ => Err(format!("module doesn't contain module `{name}`")), + _ => bail!("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(); + let file = DOCS_DIR.get_file(path).unwrap(); yaml::from_slice(file.contents()).unwrap() } /// Load details for an identifying key. #[track_caller] -fn details(key: &str) -> &str { - DETAILS +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) -> String { +pub fn urlify(title: &str) -> EcoString { title .chars() .map(|c| c.to_ascii_lowercase()) @@ -974,33 +722,47 @@ const TYPE_ORDER: &[&str] = &[ "any", "none", "auto", - "boolean", - "integer", + "bool", + "int", "float", "length", "angle", "ratio", - "relative length", + "relative", "fraction", "color", "datetime", - "string", + "duration", + "str", "bytes", "regex", "label", "content", "array", - "dictionary", - "function", - "arguments", + "dict", + "func", + "args", + "selector", "location", - "dir", + "direction", "alignment", - "2d alignment", - "selector", + "alignment2d", "stroke", ]; +/// Data about a collection of functions. +#[derive(Debug, Clone, Deserialize)] +struct GroupData { + name: EcoString, + category: EcoString, + display: EcoString, + #[serde(default)] + path: Vec<EcoString>, + #[serde(default)] + functions: Vec<EcoString>, + description: EcoString, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/typst-docs/src/link.rs b/crates/typst-docs/src/link.rs new file mode 100644 index 00000000..5d7a5485 --- /dev/null +++ b/crates/typst-docs/src/link.rs @@ -0,0 +1,102 @@ +use typst::diag::{bail, StrResult}; +use typst::eval::Func; + +use super::{get_module, GROUPS, LIBRARY}; + +/// Resolve an intra-doc link. +pub fn resolve(link: &str) -> StrResult<String> { + if link.starts_with('#') || link.starts_with("http") { + return Ok(link.to_string()); + } + + let (head, tail) = split_link(link)?; + let mut route = match resolve_known(head) { + Some(route) => route.into(), + None => resolve_definition(head)?, + }; + + if !tail.is_empty() { + route.push('/'); + route.push_str(tail); + } + + if !route.contains('#') && !route.ends_with('/') { + route.push('/'); + } + + Ok(route) +} + +/// Split a link at the first slash. +fn split_link(link: &str) -> StrResult<(&str, &str)> { + let first = link.split('/').next().unwrap_or(link); + let rest = link[first.len()..].trim_start_matches('/'); + Ok((first, rest)) +} + +/// Resolve a `$` link head to a known destination. +fn resolve_known(head: &str) -> Option<&'static str> { + Some(match head { + "$tutorial" => "/docs/tutorial/", + "$reference" => "/docs/reference/", + "$category" => "/docs/reference/", + "$syntax" => "/docs/reference/syntax/", + "$styling" => "/docs/reference/styling/", + "$scripting" => "/docs/reference/scripting/", + "$guides" => "/docs/guides/", + "$packages" => "/docs/packages/", + "$changelog" => "/docs/changelog/", + "$community" => "/docs/community/", + _ => return None, + }) +} + +/// Resolve a `$` link to a global definition. +fn resolve_definition(head: &str) -> StrResult<String> { + let mut parts = head.trim_start_matches('$').split('.').peekable(); + let mut focus = &LIBRARY.global; + while let Some(m) = parts.peek().and_then(|&name| get_module(focus, name).ok()) { + focus = m; + parts.next(); + } + + let name = parts.next().ok_or("link is missing first part")?; + let value = focus.field(name)?; + let Some(category) = focus.scope().get_category(name) else { + bail!("{name} has no category"); + }; + + // Handle grouped functions. + if let Some(group) = GROUPS + .iter() + .filter(|_| category == "math") + .find(|group| group.functions.iter().any(|func| func == name)) + { + let mut route = + format!("/docs/reference/math/{}/#functions-{}", group.name, name); + if let Some(param) = parts.next() { + route.push('-'); + route.push_str(param); + } + return Ok(route); + } + + let mut route = format!("/docs/reference/{category}/{name}/"); + if let Some(next) = parts.next() { + if value.field(next).is_ok() { + route.push_str("#definitions-"); + route.push_str(next); + } else if value + .clone() + .cast::<Func>() + .map_or(false, |func| func.param(next).is_some()) + { + route.push_str("#parameters-"); + route.push_str(next); + } else { + bail!("field {next} not found"); + } + } + + Ok(route) +} diff --git a/crates/typst-docs/src/model.rs b/crates/typst-docs/src/model.rs new file mode 100644 index 00000000..46d979a2 --- /dev/null +++ b/crates/typst-docs/src/model.rs @@ -0,0 +1,170 @@ +use ecow::EcoString; +use heck::ToKebabCase; +use serde::Serialize; + +use crate::html::Html; + +/// Details about a documentation page and its children. +#[derive(Debug, Serialize)] +pub struct PageModel { + pub route: EcoString, + pub title: EcoString, + pub description: EcoString, + pub part: Option<&'static str>, + pub outline: Vec<OutlineItem>, + pub body: BodyModel, + pub children: Vec<Self>, +} + +impl PageModel { + pub fn with_route(self, route: &str) -> Self { + Self { route: route.into(), ..self } + } + + pub fn with_part(self, part: &'static str) -> Self { + Self { part: Some(part), ..self } + } +} + +/// An element in the "On This Page" outline. +#[derive(Debug, Clone, Serialize)] +pub struct OutlineItem { + pub id: EcoString, + pub name: EcoString, + pub children: Vec<Self>, +} + +impl OutlineItem { + /// Create an outline item from a name with auto-generated id. + pub fn from_name(name: &str) -> Self { + Self { + id: name.to_kebab_case().into(), + name: name.into(), + children: vec![], + } + } +} + +/// Details about the body of a documentation page. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "kind", content = "content")] +pub enum BodyModel { + Html(Html), + Category(CategoryModel), + Func(FuncModel), + Group(GroupModel), + Type(TypeModel), + Symbols(SymbolsModel), + Packages(Html), +} + +/// Details about a category. +#[derive(Debug, Serialize)] +pub struct CategoryModel { + pub name: EcoString, + pub details: Html, + pub items: Vec<CategoryItem>, + pub shorthands: Option<ShorthandsModel>, +} + +/// Details about a category item. +#[derive(Debug, Serialize)] +pub struct CategoryItem { + pub name: EcoString, + pub route: EcoString, + pub oneliner: EcoString, + pub code: bool, +} + +/// Details about a function. +#[derive(Debug, Serialize)] +pub struct FuncModel { + pub path: Vec<EcoString>, + pub name: EcoString, + pub title: &'static str, + pub keywords: &'static [&'static str], + pub oneliner: &'static str, + pub element: bool, + pub details: Html, + /// This example is only for nested function models. Others can have + /// their example directly in their detals. + pub example: Option<Html>, + #[serde(rename = "self")] + pub self_: bool, + pub params: Vec<ParamModel>, + pub returns: Vec<&'static str>, + pub scope: Vec<FuncModel>, +} + +/// 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: EcoString, + pub details: Html, +} + +/// Details about a group of functions. +#[derive(Debug, Serialize)] +pub struct GroupModel { + pub name: EcoString, + pub title: EcoString, + pub details: Html, + pub functions: Vec<FuncModel>, +} + +/// Details about a type. +#[derive(Debug, Serialize)] +pub struct TypeModel { + pub name: &'static str, + pub title: &'static str, + pub keywords: &'static [&'static str], + pub oneliner: &'static str, + pub details: Html, + pub constructor: Option<FuncModel>, + pub scope: Vec<FuncModel>, +} + +/// 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, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SymbolModel { + pub name: EcoString, + pub codepoint: u32, + pub accent: bool, + pub unicode_name: Option<EcoString>, + pub alternates: Vec<EcoString>, + pub markup_shorthand: Option<&'static str>, + pub math_shorthand: Option<&'static str>, +} + +/// Shorthands listed on a category page. +#[derive(Debug, Serialize)] +pub struct ShorthandsModel { + pub markup: Vec<SymbolModel>, + pub math: Vec<SymbolModel>, +} |
