summaryrefslogtreecommitdiff
path: root/docs/src
diff options
context:
space:
mode:
Diffstat (limited to 'docs/src')
-rw-r--r--docs/src/contribs.rs86
-rw-r--r--docs/src/html.rs469
-rw-r--r--docs/src/lib.rs803
-rw-r--r--docs/src/link.rs108
-rw-r--r--docs/src/main.rs148
-rw-r--r--docs/src/model.rs173
6 files changed, 1787 insertions, 0 deletions
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('\"', "&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, 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 = &params[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>,
+}