summaryrefslogtreecommitdiff
path: root/crates/typst-docs/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-docs/src')
-rw-r--r--crates/typst-docs/src/contribs.rs86
-rw-r--r--crates/typst-docs/src/html.rs513
-rw-r--r--crates/typst-docs/src/lib.rs1002
3 files changed, 1601 insertions, 0 deletions
diff --git a/crates/typst-docs/src/contribs.rs b/crates/typst-docs/src/contribs.rs
new file mode 100644
index 00000000..901b5f69
--- /dev/null
+++ b/crates/typst-docs/src/contribs.rs
@@ -0,0 +1,86 @@
+use std::cmp::Reverse;
+use std::collections::HashMap;
+use std::fmt::Write;
+
+use serde::Deserialize;
+
+use super::{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('\"', "&quot;").replace('&', "&amp;");
+ 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, Deserialize)]
+pub struct Commit {
+ author: Author,
+}
+
+/// A commit author.
+#[derive(Debug, Deserialize)]
+pub struct Author {
+ login: String,
+ avatar_url: String,
+}
diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs
new file mode 100644
index 00000000..0d40f46b
--- /dev/null
+++ b/crates/typst-docs/src/html.rs
@@ -0,0 +1,513 @@
+use std::ops::Range;
+
+use comemo::Prehashed;
+use pulldown_cmark as md;
+use typed_arena::Arena;
+use typst::diag::FileResult;
+use typst::eval::Datetime;
+use typst::file::FileId;
+use typst::font::{Font, FontBook};
+use typst::geom::{Point, Size};
+use typst::syntax::Source;
+use typst::util::Bytes;
+use typst::World;
+use yaml_front_matter::YamlFrontMatter;
+
+use super::*;
+
+/// HTML documentation.
+#[derive(Serialize)]
+#[serde(transparent)]
+pub struct Html {
+ raw: String,
+ #[serde(skip)]
+ md: String,
+ #[serde(skip)]
+ description: Option<String>,
+ #[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) -> 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 {
+ 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_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 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("</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<String> {
+ 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: String,
+}
+
+struct Handler<'a> {
+ resolver: &'a dyn Resolver,
+ lang: Option<String>,
+ code: String,
+ outline: Vec<OutlineItem>,
+ id_base: String,
+ ids: &'a Arena<String>,
+}
+
+impl<'a> Handler<'a> {
+ fn new(resolver: &'a dyn Resolver, id_base: String, ids: &'a Arena<String>) -> Self {
+ Self {
+ resolver,
+ lang: None,
+ code: String::new(),
+ outline: vec![],
+ id_base,
+ 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, Some(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);
+ }
+ }
+
+ // 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 = self
+ .handle_link(dest)
+ .unwrap_or_else(|| panic!("invalid link: {dest}"))
+ .into();
+ }
+
+ // 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::ide::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 = String::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(file) = FILES.get_file(link) {
+ self.resolver.image(link, file.contents())
+ } else if let Some(url) = self.resolver.link(link) {
+ url
+ } else {
+ panic!("missing image: {link}")
+ }
+ }
+
+ fn handle_heading(&mut self, id: &mut &'a str, level: &mut md::HeadingLevel) {
+ if *level == md::HeadingLevel::H1 {
+ return;
+ }
+
+ // Special case for things like "v0.3.0".
+ let name = if id.starts_with('v') && id.contains('.') {
+ id.to_string()
+ } else {
+ id.to_title_case()
+ };
+
+ 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;
+ }
+
+ // 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![] });
+ }
+
+ fn handle_link(&self, link: &str) -> Option<String> {
+ if link.starts_with('#') || link.starts_with("http") {
+ return Some(link.into());
+ }
+
+ 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);
+ }
+
+ if !route.contains('#') && !route.ends_with('/') {
+ route.push('/');
+ }
+
+ Some(route)
+ }
+}
+
+/// 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") {
+ let set = &*typst_library::text::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_library::text::THEME,
+ )
+ .expect("failed to highlight code");
+ return Html::new(buf);
+ }
+
+ let root = typst::syntax::parse(&display);
+ let highlighted = Html::new(typst::ide::highlight_html(&root));
+ if lang == "typ" {
+ return Html::new(format!("<pre>{}</pre>", highlighted.as_str()));
+ }
+
+ let id = FileId::new(None, Path::new("/main.typ"));
+ let source = Source::new(id, compile);
+ let world = DocWorld(source);
+ let mut frames = match typst::compile(&world) {
+ Ok(doc) => doc.pages,
+ 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 {
+ frames[0].translate(Point::new(-x, -y));
+ *frames[0].size_mut() = Size::new(w, h);
+ }
+
+ if single {
+ frames.truncate(1);
+ }
+
+ resolver.example(highlighted, &frames)
+}
+
+/// 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) {
+ *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,
+ };
+}
+
+/// 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(FILES
+ .get_file(id.path().strip_prefix("/").unwrap())
+ .unwrap_or_else(|| panic!("failed to load {:?}", id.path().display()))
+ .contents()
+ .into())
+ }
+
+ 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/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs
new file mode 100644
index 00000000..d7f816d2
--- /dev/null
+++ b/crates/typst-docs/src/lib.rs
@@ -0,0 +1,1002 @@
+//! Documentation provider for Typst.
+
+mod contribs;
+mod html;
+
+pub use contribs::{contributors, Author, Commit};
+pub use html::Html;
+
+use std::fmt::{self, Debug, Formatter};
+use std::path::Path;
+
+use comemo::Prehashed;
+use heck::ToTitleCase;
+use include_dir::{include_dir, Dir};
+use once_cell::sync::Lazy;
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+use serde_yaml as yaml;
+use typst::doc::Frame;
+use typst::eval::{CastInfo, Func, FuncInfo, Library, Module, ParamInfo, 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 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 LIBRARY: Lazy<Prehashed<Library>> = Lazy::new(|| {
+ let mut lib = typst_library::build();
+ lib.styles
+ .set(PageElem::set_width(Smart::Custom(Abs::pt(240.0).into())));
+ lib.styles.set(PageElem::set_height(Smart::Auto));
+ lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom(
+ Abs::pt(15.0).into(),
+ )))));
+ typst::eval::set_lang_items(lib.items.clone());
+ Prehashed::new(lib)
+});
+
+/// 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),
+ packages_page(resolver),
+ markdown_page(resolver, "/docs/", "changelog.md"),
+ markdown_page(resolver, "/docs/", "community.md"),
+ ]
+}
+
+/// Resolve consumer dependencies.
+pub trait Resolver {
+ /// Try to resolve a link that the system cannot resolve itself.
+ fn link(&self, link: &str) -> Option<String>;
+
+ /// Produce an URL for an image file.
+ fn image(&self, filename: &str, data: &[u8]) -> String;
+
+ /// Produce HTML for an example.
+ fn example(&self, source: Html, frames: &[Frame]) -> Html;
+
+ /// Determine the commits between two tags.
+ 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 }
+ }
+}
+
+/// 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
+ .get_dir("tutorial")
+ .unwrap()
+ .files()
+ .filter(|file| file.path() != Path::new("tutorial/welcome.md"))
+ .map(|file| markdown_page(resolver, "/docs/tutorial/", file.path()))
+ .collect();
+ page
+}
+
+/// Build the reference.
+fn reference_pages(resolver: &dyn Resolver) -> PageModel {
+ let mut page = markdown_page(resolver, "/docs/", "reference/welcome.md");
+ page.children = vec![
+ markdown_page(resolver, "/docs/reference/", "reference/syntax.md")
+ .with_part("Language"),
+ markdown_page(resolver, "/docs/reference/", "reference/styling.md"),
+ markdown_page(resolver, "/docs/reference/", "reference/scripting.md"),
+ types_page(resolver, "/docs/reference/"),
+ category_page(resolver, "text").with_part("Content"),
+ 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 {
+ let mut page = markdown_page(resolver, "/docs/", "guides/welcome.md");
+ page.children =
+ vec![markdown_page(resolver, "/docs/guides/", "guides/guide-for-latex-users.md")];
+ page
+}
+
+/// Build the packages section.
+fn packages_page(resolver: &dyn Resolver) -> PageModel {
+ PageModel {
+ route: "/docs/packages/".into(),
+ title: "Packages".into(),
+ description: "Packages for Typst.".into(),
+ part: None,
+ outline: vec![],
+ body: BodyModel::Packages(Html::markdown(resolver, 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),
+ 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>,
+}
+
+/// Details about a category item.
+#[derive(Debug, Serialize)]
+pub struct CategoryItem {
+ pub name: String,
+ pub route: String,
+ pub oneliner: String,
+ pub code: bool,
+}
+
+/// Create a page for a category.
+#[track_caller]
+fn category_page(resolver: &dyn Resolver, category: &str) -> PageModel {
+ let route = format!("/docs/reference/{category}/");
+ let mut children = vec![];
+ let mut items = vec![];
+
+ let focus = match category {
+ "math" => &LIBRARY.math,
+ "calculate" => module(&LIBRARY.global, "calc").unwrap(),
+ _ => &LIBRARY.global,
+ };
+
+ let parents: &[&str] = match category {
+ "math" => &[],
+ "calculate" => &["calc"],
+ _ => &[],
+ };
+
+ let grouped = match category {
+ "math" => GROUPS.as_slice(),
+ _ => &[],
+ };
+
+ // Add functions.
+ for (_, value) in focus.scope().iter() {
+ let Value::Func(func) = value else { continue };
+ let Some(info) = func.info() else { continue };
+ if info.category != category {
+ continue;
+ }
+
+ // Skip grouped functions.
+ if grouped
+ .iter()
+ .flat_map(|group| &group.functions)
+ .any(|f| f == info.name)
+ {
+ 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);
+ }
+
+ // 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);
+ }
+
+ 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![],
+ });
+ }
+
+ children.sort_by_cached_key(|child| child.title.clone());
+ items.sort_by_cached_key(|item| item.name.clone());
+
+ // Add symbol pages. These are ordered manually.
+ if category == "symbols" {
+ for module in ["sym", "emoji"] {
+ let subpage = symbol_page(resolver, &route, module);
+ items.push(CategoryItem {
+ name: module.into(),
+ route: subpage.route.clone(),
+ oneliner: oneliner(details(module)).into(),
+ code: true,
+ });
+ children.push(subpage);
+ }
+ }
+
+ let name = category.to_title_case();
+ let kind = match category {
+ "symbols" => "Modules",
+ _ => "Functions",
+ };
+
+ PageModel {
+ route,
+ title: name.clone(),
+ description: format!("Documentation for functions related to {name} in Typst."),
+ part: None,
+ outline: category_outline(kind),
+ body: BodyModel::Category(CategoryModel {
+ name,
+ details: Html::markdown(resolver, details(category)),
+ kind,
+ items,
+ }),
+ 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>,
+}
+
+/// Create a page for a function.
+fn function_page(
+ resolver: &dyn Resolver,
+ parent: &str,
+ func: &Func,
+ info: &FuncInfo,
+ parents: &[&'static str],
+) -> PageModel {
+ let model = func_model(resolver, func, info, parents, "");
+ PageModel {
+ route: format!("{parent}{}/", urlify(info.name)),
+ title: info.display.to_string(),
+ description: format!("Documentation for the `{}` function.", info.name),
+ part: None,
+ outline: func_outline(&model, "", true),
+ body: BodyModel::Func(model),
+ children: vec![],
+ }
+}
+
+/// Produce a function's model.
+fn func_model(
+ resolver: &dyn Resolver,
+ func: &Func,
+ info: &FuncInfo,
+ parents: &[&'static str],
+ id_base: &str,
+) -> 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 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 returns = vec![];
+ casts(resolver, &mut returns, &mut vec![], &info.returns);
+ returns.sort_by_key(|ty| type_index(ty));
+ if returns == ["none"] {
+ returns.clear();
+ }
+
+ FuncModel {
+ path,
+ display: info.display,
+ keywords: info.keywords,
+ oneliner: oneliner(docs),
+ 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(),
+ 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![],
+ });
+ }
+
+ 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 mut types = vec![];
+ let mut strings = vec![];
+ casts(resolver, &mut types, &mut strings, &info.cast);
+ if !strings.is_empty() && !types.contains(&"string") {
+ types.push("string");
+ }
+ types.sort_by_key(|ty| type_index(ty));
+
+ let mut details = info.docs;
+ let mut example = None;
+ if let Some(mut i) = info.docs.find("```example") {
+ while info.docs[..i].ends_with('`') {
+ i -= 1;
+ }
+ details = &info.docs[..i];
+ example = Some(&info.docs[i..]);
+ }
+
+ ParamModel {
+ name: info.name,
+ details: Html::markdown(resolver, details),
+ example: example.map(|md| Html::markdown(resolver, md)),
+ types,
+ strings,
+ default: info.default.map(|default| {
+ let node = typst::syntax::parse_code(&default().repr());
+ Html::new(typst::ide::highlight_html(&node))
+ }),
+ positional: info.positional,
+ named: info.named,
+ required: info.required,
+ variadic: info.variadic,
+ settable: info.settable,
+ }
+}
+
+/// Process cast information into types and strings.
+fn casts(
+ resolver: &dyn Resolver,
+ types: &mut Vec<&'static str>,
+ strings: &mut Vec<StrParam>,
+ info: &CastInfo,
+) {
+ match info {
+ CastInfo::Any => types.push("any"),
+ CastInfo::Value(Value::Str(string), docs) => strings.push(StrParam {
+ string: string.to_string(),
+ details: Html::markdown(resolver, docs),
+ }),
+ CastInfo::Value(..) => {}
+ CastInfo::Type(ty) => types.push(ty),
+ CastInfo::Union(options) => {
+ for option in options {
+ casts(resolver, types, strings, option);
+ }
+ }
+ }
+}
+
+/// A collection of symbols.
+#[derive(Debug, Serialize)]
+pub struct TypeModel {
+ pub name: String,
+ pub oneliner: &'static str,
+ pub details: Html,
+ pub methods: Vec<MethodModel>,
+}
+
+/// 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>,
+}
+
+/// Create a page for the types.
+fn types_page(resolver: &dyn Resolver, parent: &str) -> PageModel {
+ let route = format!("{parent}types/");
+ let mut children = vec![];
+ let mut items = vec![];
+
+ for model in type_models(resolver) {
+ let route = format!("{route}{}/", 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),
+ 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,
+ }),
+ children,
+ }
+}
+
+/// 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');
+ }
+
+ 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),
+ }
+}
+
+/// 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### ")));
+ }
+ }
+
+ methods
+}
+
+/// Produce a method's model.
+fn method_model(resolver: &dyn Resolver, part: &'static str) -> MethodModel {
+ let mut s = unscanny::Scanner::new(part);
+ let mut params = vec![];
+ let mut returns = vec![];
+
+ let name = s.eat_until('(').trim();
+ s.expect("()");
+ let docs = s.eat_until("\n- ").trim();
+
+ while s.eat_if("\n- ") {
+ let name = s.eat_until(':');
+ s.expect(": ");
+ let types: Vec<_> =
+ s.eat_until(['(', '\n']).split(" or ").map(str::trim).collect();
+ if !types.iter().all(|ty| type_index(ty) != usize::MAX) {
+ panic!(
+ "unknown type in method {} parameter {}",
+ name,
+ types.iter().find(|ty| type_index(ty) == usize::MAX).unwrap()
+ )
+ }
+
+ if name == "returns" {
+ returns = types;
+ continue;
+ }
+
+ s.expect('(');
+
+ let mut named = false;
+ let mut positional = false;
+ let mut required = false;
+ let mut variadic = false;
+ for part in s.eat_until(')').split(',').map(str::trim) {
+ match part {
+ "named" => named = true,
+ "positional" => positional = true,
+ "required" => required = true,
+ "variadic" => variadic = true,
+ _ => panic!("unknown parameter flag {:?}", part),
+ }
+ }
+
+ s.expect(')');
+
+ params.push(ParamModel {
+ name,
+ details: Html::markdown(resolver, s.eat_until("\n- ").trim()),
+ example: None,
+ types,
+ strings: vec![],
+ default: None,
+ positional,
+ named,
+ required,
+ variadic,
+ settable: false,
+ });
+ }
+
+ MethodModel {
+ name,
+ details: Html::markdown(resolver, docs),
+ params,
+ returns,
+ }
+}
+
+/// 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![],
+ }];
+
+ outline.extend(methods_outline(&model.methods));
+ 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(),
+ })
+}
+
+/// 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(),
+ }
+}
+
+/// 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, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SymbolModel {
+ pub name: String,
+ pub shorthand: Option<&'static str>,
+ pub codepoint: u32,
+ pub accent: bool,
+ pub unicode_name: Option<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();
+
+ let mut list = vec![];
+ for (name, value) in module.scope().iter() {
+ let Value::Symbol(symbol) = value else { continue };
+ let complete = |variant: &str| {
+ if variant.is_empty() {
+ name.into()
+ } else {
+ format!("{}.{}", name, variant)
+ }
+ };
+
+ for (variant, c) in symbol.variants() {
+ list.push(SymbolModel {
+ name: complete(variant),
+ shorthand: typst::syntax::ast::Shorthand::LIST
+ .iter()
+ .copied()
+ .find(|&(_, x)| x == c)
+ .map(|(s, _)| s),
+ codepoint: c as u32,
+ accent: typst::eval::Symbol::combining_accent(c).is_some(),
+ unicode_name: unicode_names2::name(c)
+ .map(|s| s.to_string().to_title_case()),
+ alternates: symbol
+ .variants()
+ .filter(|(other, _)| other != &variant)
+ .map(|(other, _)| complete(other))
+ .collect(),
+ });
+ }
+ }
+
+ let title = match name {
+ "sym" => "General",
+ "emoji" => "Emoji",
+ _ => unreachable!(),
+ };
+
+ PageModel {
+ route: format!("{parent}{name}/"),
+ title: title.into(),
+ description: format!("Documentation for the `{name}` module."),
+ part: None,
+ outline: vec![],
+ body: BodyModel::Symbols(SymbolsModel {
+ name: title,
+ details: Html::markdown(resolver, details(name)),
+ list,
+ }),
+ children: vec![],
+ }
+}
+
+/// Data about a collection of functions.
+#[derive(Debug, Deserialize)]
+struct GroupData {
+ name: String,
+ 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> {
+ match parent.scope().get(name) {
+ Some(Value::Module(module)) => Ok(module),
+ _ => Err(format!("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();
+ yaml::from_slice(file.contents()).unwrap()
+}
+
+/// Load details for an identifying key.
+#[track_caller]
+fn details(key: &str) -> &str {
+ DETAILS
+ .get(&yaml::Value::String(key.into()))
+ .and_then(|value| value.as_str())
+ .unwrap_or_else(|| panic!("missing details for {key}"))
+}
+
+/// Turn a title into an URL fragment.
+pub fn urlify(title: &str) -> String {
+ title
+ .chars()
+ .map(|c| c.to_ascii_lowercase())
+ .map(|c| match c {
+ 'a'..='z' | '0'..='9' => c,
+ _ => '-',
+ })
+ .collect()
+}
+
+/// Extract the first line of documentation.
+fn oneliner(docs: &str) -> &str {
+ docs.lines().next().unwrap_or_default()
+}
+
+/// The order of types in the documentation.
+fn type_index(ty: &str) -> usize {
+ TYPE_ORDER.iter().position(|&v| v == ty).unwrap_or(usize::MAX)
+}
+
+const TYPE_ORDER: &[&str] = &[
+ "any",
+ "none",
+ "auto",
+ "boolean",
+ "integer",
+ "float",
+ "length",
+ "angle",
+ "ratio",
+ "relative length",
+ "fraction",
+ "color",
+ "datetime",
+ "string",
+ "regex",
+ "label",
+ "content",
+ "array",
+ "dictionary",
+ "function",
+ "arguments",
+ "location",
+ "dir",
+ "alignment",
+ "2d alignment",
+ "selector",
+ "stroke",
+];
+
+#[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, _: Html, _: &[Frame]) -> Html {
+ Html::new(String::new())
+ }
+
+ fn image(&self, _: &str, _: &[u8]) -> String {
+ String::new()
+ }
+
+ fn commits(&self, _: &str, _: &str) -> Vec<Commit> {
+ vec![]
+ }
+ }
+}