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, #[serde(skip)] outline: Vec, } 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::(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("

").then(|| s.eat_until("

")) } /// The outline of the HTML. pub fn outline(&self) -> Vec { self.outline.clone() } /// The description from the front matter. pub fn description(&self) -> Option { 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, code: String, outline: Vec, id_base: String, ids: &'a Arena, } impl<'a> Handler<'a> { fn new(resolver: &'a dyn Resolver, id_base: String, ids: &'a Arena) -> 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(" { 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(" { 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 { 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::>() .try_into() .ok(); } } if lang.is_empty() { let mut buf = String::from("
");
        md::escape::escape_html(&mut buf, &display).unwrap();
        buf.push_str("
"); 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!("
{}
", 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> { 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 } fn book(&self) -> &Prehashed { &FONTS.0 } fn main(&self) -> Source { self.0.clone() } fn source(&self, _: FileId) -> FileResult { Ok(self.0.clone()) } fn file(&self, id: FileId) -> FileResult { 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 { Some(FONTS.1[index].clone()) } fn today(&self, _: Option) -> Option { Some(Datetime::from_ymd(1970, 1, 1).unwrap()) } }