diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/Cargo.toml | 41 | ||||
| -rw-r--r-- | docs/src/contribs.rs | 86 | ||||
| -rw-r--r-- | docs/src/html.rs | 469 | ||||
| -rw-r--r-- | docs/src/lib.rs | 803 | ||||
| -rw-r--r-- | docs/src/link.rs | 108 | ||||
| -rw-r--r-- | docs/src/main.rs | 148 | ||||
| -rw-r--r-- | docs/src/model.rs | 173 | ||||
| -rw-r--r-- | docs/tutorial/1-writing.md | 4 | ||||
| -rw-r--r-- | docs/tutorial/2-formatting.md | 2 | ||||
| -rw-r--r-- | docs/tutorial/3-advanced.md | 4 |
10 files changed, 1833 insertions, 5 deletions
diff --git a/docs/Cargo.toml b/docs/Cargo.toml new file mode 100644 index 00000000..0342d612 --- /dev/null +++ b/docs/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "typst-docs" +version = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +publish = false + +[lib] +doctest = false +bench = false + +[[bin]] +name = "typst-docs" +required-features = ["cli"] + +[features] +cli = ["clap", "typst-render", "serde_json"] + +[dependencies] +typst = { workspace = true } +typst-assets = { workspace = true, features = ["fonts"] } +typst-dev-assets = { workspace = true } +comemo = { workspace = true } +ecow = { workspace = true } +heck = { workspace = true } +once_cell = { workspace = true } +pulldown-cmark = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +syntect = { workspace = true, features = ["html"] } +typed-arena = { workspace = true } +unicode_names2 = { workspace = true } +unscanny = { workspace = true } +yaml-front-matter = { workspace = true } +clap = { workspace = true, optional = true } +typst-render = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } + +[lints] +workspace = true diff --git a/docs/src/contribs.rs b/docs/src/contribs.rs new file mode 100644 index 00000000..58a730e2 --- /dev/null +++ b/docs/src/contribs.rs @@ -0,0 +1,86 @@ +use std::cmp::Reverse; +use std::collections::HashMap; +use std::fmt::Write; + +use serde::{Deserialize, Serialize}; + +use crate::{Html, Resolver}; + +/// Build HTML detailing the contributors between two tags. +pub fn contributors(resolver: &dyn Resolver, from: &str, to: &str) -> Option<Html> { + let staff = ["laurmaedje", "reknih"]; + + // Determine number of contributions per person. + let mut contributors = HashMap::<String, Contributor>::new(); + for commit in resolver.commits(from, to) { + contributors + .entry(commit.author.login.clone()) + .or_insert_with(|| Contributor { + login: commit.author.login, + avatar: commit.author.avatar_url, + contributions: 0, + }) + .contributions += 1; + } + + // Keep only non-staff people. + let mut contributors: Vec<_> = contributors + .into_values() + .filter(|c| !staff.contains(&c.login.as_str())) + .collect(); + + // Sort by highest number of commits. + contributors.sort_by_key(|c| (Reverse(c.contributions), c.login.clone())); + if contributors.is_empty() { + return None; + } + + let mut html = "Thanks to everyone who contributed to this release!".to_string(); + html += "<ul class=\"contribs\">"; + + for Contributor { login, avatar, contributions } in contributors { + let login = login.replace('\"', """).replace('&', "&"); + let avatar = avatar.replace("?v=", "?s=64&v="); + let s = if contributions > 1 { "s" } else { "" }; + write!( + html, + r#"<li> + <a href="https://github.com/{login}" target="_blank"> + <img + width="64" + height="64" + src="{avatar}" + alt="GitHub avatar of {login}" + title="@{login} made {contributions} contribution{s}" + crossorigin="anonymous" + > + </a> + </li>"# + ) + .unwrap(); + } + + html += "</ul>"; + + Some(Html::new(html)) +} + +#[derive(Debug)] +struct Contributor { + login: String, + avatar: String, + contributions: usize, +} + +/// A commit on the `typst` repository. +#[derive(Debug, Serialize, Deserialize)] +pub struct Commit { + author: Author, +} + +/// A commit author. +#[derive(Debug, Serialize, Deserialize)] +pub struct Author { + login: String, + avatar_url: String, +} diff --git a/docs/src/html.rs b/docs/src/html.rs new file mode 100644 index 00000000..912ad367 --- /dev/null +++ b/docs/src/html.rs @@ -0,0 +1,469 @@ +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, StrResult}; +use typst::eval::Tracer; +use typst::foundations::{Bytes, Datetime}; +use typst::layout::{Abs, Point, Size}; +use typst::syntax::{FileId, Source, VirtualPath}; +use typst::text::{Font, FontBook}; +use typst::{Library, World}; +use unscanny::Scanner; +use yaml_front_matter::YamlFrontMatter; + +use crate::{contributors, OutlineItem, Resolver, FONTS, LIBRARY}; + +/// HTML documentation. +#[derive(Serialize)] +#[serde(transparent)] +pub struct Html { + raw: String, + #[serde(skip)] + md: String, + #[serde(skip)] + description: Option<EcoString>, + #[serde(skip)] + outline: Vec<OutlineItem>, +} + +impl Html { + /// Create HTML from a raw string. + pub fn new(raw: String) -> Self { + Self { + md: String::new(), + raw, + description: None, + outline: vec![], + } + } + + /// Convert markdown to HTML. + #[track_caller] + pub fn markdown(resolver: &dyn Resolver, md: &str, nesting: Option<usize>) -> Self { + let mut text = md; + let mut description = None; + let document = YamlFrontMatter::parse::<Metadata>(md); + if let Ok(document) = &document { + text = &document.content; + description = Some(document.metadata.description.clone()) + } + + let options = md::Options::ENABLE_TABLES + | md::Options::ENABLE_FOOTNOTES + | md::Options::ENABLE_STRIKETHROUGH + | md::Options::ENABLE_HEADING_ATTRIBUTES; + + let ids = Arena::new(); + 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); + raw.truncate(raw.trim_end().len()); + + Html { + md: text.into(), + raw, + description, + outline: handler.outline, + } + } + + /// The raw HTML. + pub fn as_str(&self) -> &str { + &self.raw + } + + /// The original Markdown, if any. + pub fn md(&self) -> &str { + &self.md + } + + /// The title of the HTML. + /// + /// Returns `None` if the HTML doesn't start with an `h1` tag. + pub fn title(&self) -> Option<&str> { + let mut s = Scanner::new(&self.raw); + s.eat_if("<h1").then(|| { + s.eat_until('>'); + s.eat_if('>'); + s.eat_until("</h1>") + }) + } + + /// The outline of the HTML. + pub fn outline(&self) -> Vec<OutlineItem> { + self.outline.clone() + } + + /// The description from the front matter. + pub fn description(&self) -> Option<EcoString> { + self.description.clone() + } +} + +impl Debug for Html { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "Html({:?})", self.title().unwrap_or("..")) + } +} + +/// Front matter metadata. +#[derive(Deserialize)] +struct Metadata { + description: EcoString, +} + +struct Handler<'a> { + text: &'a str, + resolver: &'a dyn Resolver, + peeked: Option<md::CowStr<'a>>, + lang: Option<EcoString>, + code: EcoString, + outline: Vec<OutlineItem>, + nesting: Option<usize>, + ids: &'a Arena<String>, +} + +impl<'a> Handler<'a> { + 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: EcoString::new(), + outline: vec![], + nesting, + ids, + } + } + + fn handle(&mut self, event: &mut md::Event<'a>) -> bool { + match event { + // Rewrite Markdown images. + md::Event::Start(md::Tag::Image(_, path, _)) => { + *path = self.handle_image(path).into(); + } + + // Rewrite HTML images. + md::Event::Html(html) if html.starts_with("<img") => { + let range = html_attr_range(html, "src").unwrap(); + let path = &html[range.clone()]; + let mut buf = html.to_string(); + buf.replace_range(range, &self.handle_image(path)); + *html = buf.into(); + } + + // Register HTML headings for the outline. + md::Event::Start(md::Tag::Heading(level, id, _)) => { + self.handle_heading(id, level); + } + + // Also handle heading closings. + md::Event::End(md::Tag::Heading(level, _, _)) => { + nest_heading(level, self.nesting()); + } + + // Rewrite contributor sections. + md::Event::Html(html) if html.starts_with("<contributors") => { + let from = html_attr(html, "from").unwrap(); + let to = html_attr(html, "to").unwrap(); + let Some(output) = contributors(self.resolver, from, to) else { + return false; + }; + *html = output.raw.into(); + } + + // Rewrite links. + md::Event::Start(md::Tag::Link(ty, dest, _)) => { + assert!( + matches!(ty, md::LinkType::Inline | md::LinkType::Reference), + "unsupported link type: {ty:?}", + ); + + *dest = match self.handle_link(dest) { + Ok(link) => link.into(), + Err(err) => panic!("invalid link: {dest} ({err})"), + }; + } + + // Inline raw. + md::Event::Code(code) => { + let mut chars = code.chars(); + let parser = match (chars.next(), chars.next_back()) { + (Some('['), Some(']')) => typst::syntax::parse, + (Some('{'), Some('}')) => typst::syntax::parse_code, + _ => return true, + }; + + let root = parser(&code[1..code.len() - 1]); + let html = typst::syntax::highlight_html(&root); + *event = md::Event::Html(html.into()); + } + + // Code blocks. + md::Event::Start(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(lang))) => { + self.lang = Some(lang.as_ref().into()); + self.code = EcoString::new(); + return false; + } + md::Event::End(md::Tag::CodeBlock(md::CodeBlockKind::Fenced(_))) => { + let Some(lang) = self.lang.take() else { return false }; + let html = code_block(self.resolver, &lang, &self.code); + *event = md::Event::Html(html.raw.into()); + } + + // Example with preview. + md::Event::Text(text) => { + if self.lang.is_some() { + self.code.push_str(text); + return false; + } + } + + _ => {} + } + + true + } + + fn handle_image(&self, link: &str) -> String { + if let Some(data) = typst_dev_assets::get_by_name(link) { + self.resolver.image(link, data) + } else if let Some(url) = self.resolver.link(link) { + url + } else { + panic!("missing image: {link}") + } + } + + 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.into() + } else { + id.to_title_case().into() + }; + + let mut children = &mut self.outline; + let mut depth = *level as usize; + while depth > 2 { + if !children.is_empty() { + children = &mut children.last_mut().unwrap().children; + } + depth -= 1; + } + + children.push(OutlineItem { id: id.into(), name, children: vec![] }); + } + + fn handle_link(&self, link: &str) -> StrResult<String> { + if let Some(link) = self.resolver.link(link) { + return Ok(link); + } + + crate::link::resolve(link, self.resolver.base()) + } + + fn nesting(&self) -> usize { + match self.nesting { + Some(nesting) => nesting, + None => panic!("headings are not allowed here:\n{}", self.text), + } + } +} + +/// Render a code block to HTML. +fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html { + let mut display = String::new(); + let mut compile = String::new(); + for line in text.lines() { + if let Some(suffix) = line.strip_prefix(">>>") { + compile.push_str(suffix); + compile.push('\n'); + } else if let Some(suffix) = line.strip_prefix("<<< ") { + display.push_str(suffix); + display.push('\n'); + } else { + display.push_str(line); + display.push('\n'); + compile.push_str(line); + compile.push('\n'); + } + } + + let mut parts = lang.split(':'); + let lang = parts.next().unwrap_or(lang); + + let mut zoom: Option<[Abs; 4]> = None; + let mut single = false; + if let Some(args) = parts.next() { + single = true; + if !args.contains("single") { + zoom = args + .split(',') + .take(4) + .map(|s| Abs::pt(s.parse().unwrap())) + .collect::<Vec<_>>() + .try_into() + .ok(); + } + } + + if lang.is_empty() { + let mut buf = String::from("<pre>"); + md::escape::escape_html(&mut buf, &display).unwrap(); + buf.push_str("</pre>"); + return Html::new(buf); + } else if !matches!(lang, "example" | "typ" | "preview") { + let set = &*typst::text::RAW_SYNTAXES; + let buf = syntect::html::highlighted_html_for_string( + &display, + set, + set.find_syntax_by_token(lang) + .unwrap_or_else(|| panic!("unsupported highlighting language: {lang}")), + &typst::text::RAW_THEME, + ) + .expect("failed to highlight code"); + return Html::new(buf); + } + + let mut highlighted = None; + if matches!(lang, "example" | "typ") { + let root = typst::syntax::parse(&display); + let html = Html::new(typst::syntax::highlight_html(&root)); + if lang == "typ" { + return Html::new(format!("<pre>{}</pre>", html.as_str())); + } + highlighted = Some(html); + } + + let id = FileId::new(None, VirtualPath::new("main.typ")); + let source = Source::new(id, compile); + let world = DocWorld(source); + + let mut tracer = Tracer::new(); + let mut document = match typst::compile(&world, &mut tracer) { + Ok(doc) => doc, + Err(err) => { + let msg = &err[0].message; + panic!("while trying to compile:\n{text}:\n\nerror: {msg}"); + } + }; + + if let Some([x, y, w, h]) = zoom { + document.pages[0].frame.translate(Point::new(-x, -y)); + *document.pages[0].frame.size_mut() = Size::new(w, h); + } + + if single { + document.pages.truncate(1); + } + + let hash = typst::util::hash128(text); + resolver.example(hash, highlighted, &document) +} + +/// Extract an attribute value from an HTML element. +fn html_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> { + html.get(html_attr_range(html, attr)?) +} + +/// Extract the range of the attribute value of an HTML element. +fn html_attr_range(html: &str, attr: &str) -> Option<Range<usize>> { + let needle = format!("{attr}=\""); + let offset = html.find(&needle)? + needle.len(); + let len = html[offset..].find('"')?; + Some(offset..offset + len) +} + +/// Increase the nesting level of a Markdown heading. +fn nest_heading(level: &mut md::HeadingLevel, nesting: usize) { + *level = ((*level as usize) + nesting) + .try_into() + .unwrap_or(md::HeadingLevel::H6); +} + +/// A world for example compilations. +struct DocWorld(Source); + +impl World for DocWorld { + fn library(&self) -> &Prehashed<Library> { + &LIBRARY + } + + fn book(&self) -> &Prehashed<FontBook> { + &FONTS.0 + } + + fn main(&self) -> Source { + self.0.clone() + } + + fn source(&self, _: FileId) -> FileResult<Source> { + Ok(self.0.clone()) + } + + fn file(&self, id: FileId) -> FileResult<Bytes> { + assert!(id.package().is_none()); + Ok(Bytes::from_static( + typst_dev_assets::get_by_name( + &id.vpath().as_rootless_path().to_string_lossy(), + ) + .unwrap_or_else(|| panic!("failed to load {:?}", id.vpath())), + )) + } + + fn font(&self, index: usize) -> Option<Font> { + Some(FONTS.1[index].clone()) + } + + fn today(&self, _: Option<i64>) -> Option<Datetime> { + Some(Datetime::from_ymd(1970, 1, 1).unwrap()) + } +} diff --git a/docs/src/lib.rs b/docs/src/lib.rs new file mode 100644 index 00000000..0bb9fc86 --- /dev/null +++ b/docs/src/lib.rs @@ -0,0 +1,803 @@ +//! 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 heck::ToTitleCase; +use once_cell::sync::Lazy; +use serde::Deserialize; +use serde_yaml as yaml; +use typst::diag::{bail, StrResult}; +use typst::foundations::Bytes; +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<Vec<GroupData>> = Lazy::new(|| { + let mut groups: Vec<GroupData> = + 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<Prehashed<Library>> = Lazy::new(|| { + let mut lib = Library::default(); + 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<FontBook>, Vec<Font>)> = 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<PageModel> { + 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 that the system cannot resolve itself. + fn link(&self, link: &str) -> Option<String>; + + /// Produce an URL for an image file. + fn image(&self, filename: &str, data: &[u8]) -> String; + + /// Produce HTML for an example. + fn example(&self, hash: u128, source: Option<Html>, document: &Document) -> Html; + + /// Determine the commits between two tags. + fn commits(&self, from: &str, to: &str) -> Vec<Commit>; + + /// 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")), + ]; + 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 functions. + let scope = module.scope(); + for (name, value) in scope.iter() { + if scope.get_category(name) != Some(category) { + continue; + } + + if category == MATH { + // Skip grouped functions. + if GROUPS.iter().flat_map(|group| &group.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().map_or(false, |first| first.name == "self") { + self_ = true; + params = ¶ms[1..]; + } + + let mut returns = vec![]; + casts(resolver, &mut returns, &mut vec![], func.returns().unwrap()); + returns.sort_by_key(|ty| type_index(ty)); + if returns == ["none"] { + returns.clear(); + } + + let nesting = if nested { None } else { Some(1) }; + let (details, example) = + if nested { split_details_and_example(docs) } else { (docs, None) }; + + FuncModel { + path: path.iter().copied().map(Into::into).collect(), + name: name.into(), + title: func.title().unwrap(), + keywords: func.keywords(), + oneliner: oneliner(details), + element: func.element().is_some(), + 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<StrParam>, + 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<FuncModel> { + 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<OutlineItem> { + 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<OutlineItem> { + 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<OutlineItem> { + 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(), + unicode_name: unicode_names2::name(c) + .map(|s| s.to_string().to_title_case().into()), + alternates: symbol + .variants() + .filter(|(other, _)| other != &variant) + .map(|(other, _)| complete(other)) + .collect(), + }); + } + } + + SymbolsModel { + name: 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<EcoString>, + #[serde(default)] + filter: Vec<EcoString>, + 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<String> { + None + } + + fn example(&self, _: u128, _: Option<Html>, _: &Document) -> Html { + Html::new(String::new()) + } + + fn image(&self, _: &str, _: &[u8]) -> String { + String::new() + } + + fn commits(&self, _: &str, _: &str) -> Vec<Commit> { + vec![] + } + + fn base(&self) -> &str { + "/" + } + } +} diff --git a/docs/src/link.rs b/docs/src/link.rs new file mode 100644 index 00000000..f4d803c3 --- /dev/null +++ b/docs/src/link.rs @@ -0,0 +1,108 @@ +use typst::diag::{bail, StrResult}; +use typst::foundations::Func; + +use crate::{get_module, GROUPS, LIBRARY}; + +/// Resolve an intra-doc link. +pub fn resolve(link: &str, base: &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, base) { + Some(route) => route, + None => resolve_definition(head, base)?, + }; + + 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, base: &str) -> Option<String> { + Some(match head { + "$tutorial" => format!("{base}tutorial"), + "$reference" => format!("{base}reference"), + "$category" => format!("{base}reference"), + "$syntax" => format!("{base}reference/syntax"), + "$styling" => format!("{base}reference/styling"), + "$scripting" => format!("{base}reference/scripting"), + "$context" => format!("{base}reference/context"), + "$guides" => format!("{base}guides"), + "$packages" => format!("{base}packages"), + "$changelog" => format!("{base}changelog"), + "$community" => format!("{base}community"), + _ => return None, + }) +} + +/// Resolve a `$` link to a global definition. +fn resolve_definition(head: &str, base: &str) -> StrResult<String> { + let mut parts = head.trim_start_matches('$').split('.').peekable(); + let mut focus = &LIBRARY.global; + let mut category = None; + + while let Some(name) = parts.peek() { + if category.is_none() { + category = focus.scope().get_category(name); + } + let Ok(module) = get_module(focus, name) else { break }; + focus = module; + parts.next(); + } + + let Some(category) = category else { bail!("{head} has no category") }; + + let name = parts.next().ok_or("link is missing first part")?; + let value = focus.field(name)?; + + // Handle grouped functions. + if let Some(group) = GROUPS.iter().find(|group| { + group.category == category.name() && group.filter.iter().any(|func| func == name) + }) { + let mut route = format!( + "{}reference/{}/{}/#functions-{}", + base, group.category, group.name, name + ); + if let Some(param) = parts.next() { + route.push('-'); + route.push_str(param); + } + return Ok(route); + } + + let mut route = format!("{}reference/{}/{name}/", base, 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/docs/src/main.rs b/docs/src/main.rs new file mode 100644 index 00000000..f4414b10 --- /dev/null +++ b/docs/src/main.rs @@ -0,0 +1,148 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use clap::Parser; +use typst::model::Document; +use typst::visualize::Color; +use typst_docs::{provide, Html, Resolver}; +use typst_render::render; + +#[derive(Debug)] +struct CliResolver<'a> { + assets_dir: &'a Path, + verbose: bool, + base: &'a str, +} + +impl<'a> Resolver for CliResolver<'a> { + fn commits(&self, from: &str, to: &str) -> Vec<typst_docs::Commit> { + if self.verbose { + eprintln!("commits({from}, {to})"); + } + vec![] + } + + fn example( + &self, + hash: u128, + source: Option<Html>, + document: &Document, + ) -> typst_docs::Html { + if self.verbose { + eprintln!( + "example(0x{hash:x}, {:?} chars, Document)", + source.as_ref().map(|s| s.as_str().len()) + ); + } + + let frame = &document.pages.first().expect("page 0").frame; + let pixmap = render(frame, 2.0, Color::WHITE); + let filename = format!("{hash:x}.png"); + let path = self.assets_dir.join(&filename); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + pixmap.save_png(path.as_path()).expect("save png"); + let src = format!("{}assets/{filename}", self.base); + eprintln!("Generated example image {path:?}"); + + if let Some(code) = source { + let code_safe = code.as_str(); + Html::new(format!( + r#"<div class="previewed-code"><pre>{code_safe}</pre><div class="preview"><img src="{src}" alt="Preview" /></div></div>"# + )) + } else { + Html::new(format!( + r#"<div class="preview"><img src="{src}" alt="Preview" /></div>"# + )) + } + } + + fn image(&self, filename: &str, data: &[u8]) -> String { + if self.verbose { + eprintln!("image({filename}, {} bytes)", data.len()); + } + + let path = self.assets_dir.join(filename); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + fs::write(&path, data).expect("write image"); + eprintln!("Created {} byte image at {path:?}", data.len()); + + format!("{}assets/{filename}", self.base) + } + + fn link(&self, link: &str) -> Option<String> { + if self.verbose { + eprintln!("link({link})"); + } + None + } + + fn base(&self) -> &str { + self.base + } +} + +/// Generates the JSON representation of the documentation. This can be used to +/// generate the HTML yourself. Be warned: the JSON structure is not stable and +/// may change at any time. +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// The generation process can produce additional assets. Namely images. + /// This option controls where to spit them out. The HTML generation will + /// assume that this output directory is served at `${base_url}/assets/*`. + /// The default is `assets`. For example, if the base URL is `/docs/` then + /// the gemerated HTML might look like `<img src="/docs/assets/foo.png">` + /// even though the `--assets-dir` was set to `/tmp/images` or something. + #[arg(long, default_value = "assets")] + assets_dir: PathBuf, + + /// Write the JSON output to this file. The default is `-` which is a + /// special value that means "write to standard output". If you want to + /// write to a file named `-` then use `./-`. + #[arg(long, default_value = "-")] + out_file: PathBuf, + + /// The base URL for the documentation. This can be an absolute URL like + /// `https://example.com/docs/` or a relative URL like `/docs/`. This is + /// used as the base URL for the generated page's `.route` properties as + /// well as cross-page links. The default is `/`. If a `/` trailing slash is + /// not present then it will be added. This option also affects the HTML + /// asset references. For example: `--base /docs/` will generate + /// `<img src="/docs/assets/foo.png">`. + #[arg(long, default_value = "/")] + base: String, + + /// Enable verbose logging. This will print out all the calls to the + /// resolver and the paths of the generated assets. + #[arg(long)] + verbose: bool, +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let args = Args::parse(); + let mut base = args.base.clone(); + if !base.ends_with('/') { + base.push('/'); + } + + let resolver = CliResolver { + assets_dir: &args.assets_dir, + verbose: args.verbose, + base: &base, + }; + if args.verbose { + eprintln!("resolver: {resolver:?}"); + } + let pages = provide(&resolver); + + eprintln!("Be warned: the JSON structure is not stable and may change at any time."); + let json = serde_json::to_string_pretty(&pages)?; + + if args.out_file.to_string_lossy() == "-" { + println!("{json}"); + } else { + fs::write(&args.out_file, &*json)?; + } + + Ok(()) +} diff --git a/docs/src/model.rs b/docs/src/model.rs new file mode 100644 index 00000000..1564ef2f --- /dev/null +++ b/docs/src/model.rs @@ -0,0 +1,173 @@ +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: &'static str, + pub title: &'static str, + 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 contextual: bool, + pub details: Html, + /// This example is only for nested function models. Others can have + /// their example directly in their details. + 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: EcoString, + pub title: EcoString, + 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>, +} diff --git a/docs/tutorial/1-writing.md b/docs/tutorial/1-writing.md index 23fdb675..de2af62f 100644 --- a/docs/tutorial/1-writing.md +++ b/docs/tutorial/1-writing.md @@ -9,7 +9,7 @@ you create a new project on the Typst app. You'll be taken to the editor where you see two panels: A source panel where you compose your document and a preview panel where you see the rendered document. - + You already have a good angle for your report in mind. So let's start by writing the introduction. Enter some text in the editor panel. You'll notice that the @@ -77,7 +77,7 @@ click the button with the arrow in the top-right corner. This opens the upload dialog, in which you can pick files to upload from your computer. Select an image file for your report. - + We have seen before that specific symbols (called _markup_) have specific meaning in Typst. We can use `=`, `-`, `+`, and `_` to create headings, lists diff --git a/docs/tutorial/2-formatting.md b/docs/tutorial/2-formatting.md index a0fb3a98..58095f94 100644 --- a/docs/tutorial/2-formatting.md +++ b/docs/tutorial/2-formatting.md @@ -82,7 +82,7 @@ hitting the Escape key and opened again by typing `#` or hitting right arguments for functions. Most suggestions come with a small description of what they do. - + ## Set up the page { #page-setup } Back to set rules: When writing a rule, you choose the function depending on diff --git a/docs/tutorial/3-advanced.md b/docs/tutorial/3-advanced.md index 92aafbad..2393a474 100644 --- a/docs/tutorial/3-advanced.md +++ b/docs/tutorial/3-advanced.md @@ -16,7 +16,7 @@ left toolbar and create a team. Finally, click on the new team and go to its settings by clicking 'manage team' next to the team name. Now you can invite your supervisor by email. - + Next, move your project into the team: Open it, going to its settings by choosing the gear icon in the left toolbar and selecting your new team from the @@ -489,7 +489,7 @@ We are now in compliance with all of these styles and can submit the paper to the conference! The finished paper looks like this: <img - src="screenshots/3-advanced-paper.png" + src="3-advanced-paper.png" alt="The finished paper" style="box-shadow: 0 4px 12px rgb(89 85 101 / 20%); width: 500px; max-width: 100%; display: block; margin: 24px auto;" > |
