diff --git a/Cargo.lock b/Cargo.lock index 1f7e740d..4e5ecebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,18 +878,16 @@ dependencies = [ [[package]] name = "deno_doc" -version = "0.159.2" +version = "0.161.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3522d34540ce1053fc35d61776a090b7007f76d0a9a5222579718cce28ec800d" +checksum = "994f95dddc99d7757772ca8f97c9dc7cca6e34a3d354fa77783041edf1366453" dependencies = [ - "ammonia", "anyhow", "cfg-if", "comrak", "deno_ast", "deno_graph", "deno_path_util", - "futures", "handlebars 6.1.0", "html-escape", "import_map", @@ -909,9 +907,9 @@ dependencies = [ [[package]] name = "deno_graph" -version = "0.84.1" +version = "0.85.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4f4a14aa069087be41c2998077b0453f0191747898f96e6343f700abfc2c18" +checksum = "4c11027d9b4e9ff4f8bcb8316a1a5dd5241dc267380507e177457bc491696189" dependencies = [ "anyhow", "async-trait", @@ -934,6 +932,7 @@ dependencies = [ "thiserror", "twox-hash", "url", + "wasm_dep_analyzer", ] [[package]] @@ -2947,6 +2946,7 @@ checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" name = "registry_api" version = "0.1.0" dependencies = [ + "ammonia", "anyhow", "async-tar", "async-trait", @@ -2973,6 +2973,7 @@ dependencies = [ "jsonc-parser", "jsonwebkey", "jsonwebtoken", + "lazy_static", "oauth2", "once_cell", "opentelemetry", @@ -5190,6 +5191,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm_dep_analyzer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f270206a91783fd90625c8bb0d8fbd459d0b1d1bf209b656f713f01ae7c04b8" +dependencies = [ + "thiserror", +] + [[package]] name = "web-sys" version = "0.3.70" diff --git a/api/Cargo.toml b/api/Cargo.toml index 9dd0107c..1a0bff5f 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -78,10 +78,11 @@ deno_semver = "0.5.2" flate2 = "1" thiserror = "1" async-tar = "0.4.2" -deno_graph = "0.84.1" +deno_graph = "0.85.1" deno_ast = { version = "0.43.3", features = ["view"] } -deno_doc = { version = "0.159.2", features = ["comrak"] } +deno_doc = { version = "0.161.0", features = ["comrak"] } comrak = { version = "0.29.0", default-features = false } +ammonia = "4.0.0" async-trait = "0.1.73" jsonwebkey = { version = "0.3.5", features = ["jsonwebtoken", "jwt-convert"] } jsonwebtoken = "8.3.0" @@ -109,6 +110,7 @@ tree-sitter-rust = "0.21.2" tree-sitter-html = "0.20.3" tree-sitter-bash = "0.21.0" tree-sitter-xml = "0.6.4" +lazy_static = "1.5.0" [dev-dependencies] flate2 = "1" diff --git a/api/src/docs.rs b/api/src/docs.rs index 4b513013..2fd418b1 100644 --- a/api/src/docs.rs +++ b/api/src/docs.rs @@ -5,6 +5,7 @@ use crate::ids::PackageName; use crate::ids::ScopeName; use crate::ids::Version; use anyhow::Context; +use comrak::nodes::{Ast, AstNode, NodeValue}; use deno_ast::ModuleSpecifier; use deno_doc::html::pages::SymbolPage; use deno_doc::html::DocNodeWithContext; @@ -20,6 +21,8 @@ use deno_doc::DocNodeKind; use deno_doc::Location; use deno_semver::RangeSetOrTag; use indexmap::IndexMap; +use std::borrow::Cow; +use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; use std::sync::OnceLock; @@ -28,6 +31,228 @@ use url::Url; pub type DocNodesByUrl = IndexMap>; +pub type URLRewriter = + Arc, &str) -> String) + Send + Sync>; + +thread_local! { + static CURRENT_FILE: RefCell>> = const { RefCell::new(None) }; + static URL_REWRITER: RefCell> = const { RefCell::new(None) }; +} + +lazy_static::lazy_static! { + static ref AMMONIA: ammonia::Builder<'static> = { + let mut ammonia_builder = ammonia::Builder::default(); + + ammonia_builder + .add_tags(["video", "button", "svg", "path", "rect"]) + .add_generic_attributes(["id", "align"]) + .add_tag_attributes("button", ["data-copy"]) + .add_tag_attributes( + "svg", + [ + "class", + "width", + "height", + "viewBox", + "fill", + "xmlns", + "stroke", + "stroke-width", + "stroke-linecap", + "stroke-linejoin", + ], + ) + .add_tag_attributes( + "path", + [ + "d", + "fill", + "fill-rule", + "clip-rule", + "stroke", + "stroke-width", + "stroke-linecap", + "stroke-linejoin", + ], + ) + .add_tag_attributes("rect", ["x", "y", "width", "height", "fill"]) + .add_tag_attributes("video", ["src", "controls"]) + .add_allowed_classes("pre", ["highlight"]) + .add_allowed_classes("button", ["context_button"]) + .add_allowed_classes( + "div", + [ + "alert", + "alert-note", + "alert-tip", + "alert-important", + "alert-warning", + "alert-caution", + ], + ) + .link_rel(Some("nofollow")) + .url_relative(ammonia::UrlRelative::Custom(Box::new( + AmmoniaRelativeUrlEvaluator(), + ))) + .add_allowed_classes("span", crate::tree_sitter::CLASSES); + + ammonia_builder + }; +} + +struct AmmoniaRelativeUrlEvaluator(); + +impl<'b> ammonia::UrlRelativeEvaluate<'b> for AmmoniaRelativeUrlEvaluator { + fn evaluate<'a>(&self, url: &'a str) -> Option> { + URL_REWRITER.with(|url_rewriter| { + let rewriter = url_rewriter.borrow(); + let url_rewriter = rewriter.as_ref().unwrap(); + CURRENT_FILE.with(|current_file| { + Some( + url_rewriter(current_file.borrow().as_ref().unwrap().as_ref(), url) + .into(), + ) + }) + }) + } +} + +enum Alert { + Note, + Tip, + Important, + Warning, + Caution, +} + +fn match_node_value<'a>( + arena: &'a comrak::Arena>, + node: &'a AstNode<'a>, + options: &comrak::Options, + plugins: &comrak::Plugins, +) { + match &node.data.borrow().value { + NodeValue::BlockQuote => { + if let Some(paragraph_child) = node.first_child() { + if paragraph_child.data.borrow().value == NodeValue::Paragraph { + let alert = paragraph_child.first_child().and_then(|text_child| { + if let NodeValue::Text(text) = &text_child.data.borrow().value { + match text + .split_once(' ') + .map_or((text.as_str(), None), |(kind, title)| { + (kind, Some(title)) + }) { + ("[!NOTE]", title) => { + Some((Alert::Note, title.unwrap_or("Note").to_string())) + } + ("[!TIP]", title) => { + Some((Alert::Tip, title.unwrap_or("Tip").to_string())) + } + ("[!IMPORTANT]", title) => Some(( + Alert::Important, + title.unwrap_or("Important").to_string(), + )), + ("[!WARNING]", title) => { + Some((Alert::Warning, title.unwrap_or("Warning").to_string())) + } + ("[!CAUTION]", title) => { + Some((Alert::Caution, title.unwrap_or("Caution").to_string())) + } + _ => None, + } + } else { + None + } + }); + + if let Some((alert, title)) = alert { + let start_col = node.data.borrow().sourcepos.start; + + let document = arena.alloc(AstNode::new(RefCell::new(Ast::new( + NodeValue::Document, + start_col, + )))); + + let node_without_alert = arena.alloc(AstNode::new(RefCell::new( + Ast::new(NodeValue::Paragraph, start_col), + ))); + + for child_node in paragraph_child.children().skip(1) { + node_without_alert.append(child_node); + } + for child_node in node.children().skip(1) { + node_without_alert.append(child_node); + } + + document.append(node_without_alert); + + let html = + deno_doc::html::comrak::render_node(document, options, plugins); + + let alert_title = match alert { + Alert::Note => { + format!("{}{title}", include_str!("./docs/info-circle.svg")) + } + Alert::Tip => { + format!("{}{title}", include_str!("./docs/bulb.svg")) + } + Alert::Important => { + format!("{}{title}", include_str!("./docs/warning-message.svg")) + } + Alert::Warning => format!( + "{}{title}", + include_str!("./docs/warning-triangle.svg") + ), + Alert::Caution => { + format!("{}{title}", include_str!("./docs/warning-octagon.svg")) + } + }; + + let html = format!( + r#"
{alert_title}
{html}
"#, + match alert { + Alert::Note => "note", + Alert::Tip => "tip", + Alert::Important => "important", + Alert::Warning => "warning", + Alert::Caution => "caution", + } + ); + + let alert_node = arena.alloc(AstNode::new(RefCell::new(Ast::new( + NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock { + block_type: 6, + literal: html, + }), + start_col, + )))); + node.insert_before(alert_node); + node.detach(); + } + } + } + } + NodeValue::Link(link) => { + if link.url.ends_with(".mov") || link.url.ends_with(".mp4") { + let start_col = node.data.borrow().sourcepos.start; + + let html = format!(r#""#, link.url); + + let alert_node = arena.alloc(AstNode::new(RefCell::new(Ast::new( + NodeValue::HtmlBlock(comrak::nodes::NodeHtmlBlock { + block_type: 6, + literal: html, + }), + start_col, + )))); + node.insert_before(alert_node); + node.detach(); + } + } + _ => {} + } +} + static DENO_TYPES: OnceLock>> = OnceLock::new(); static WEB_TYPES: OnceLock, String>> = @@ -132,7 +357,7 @@ fn get_url_rewriter( base: String, github_repository: Option, is_readme: bool, -) -> deno_doc::html::comrak::URLRewriter { +) -> URLRewriter { Arc::new(move |current_file, url| { if url.starts_with('#') || url.starts_with('/') { return url.to_string(); @@ -216,6 +441,36 @@ pub fn get_generate_ctx<'a>( let package_name = format!("@{scope}/{package}"); let url_rewriter_base = format!("/{package_name}/{version}"); + let url_rewriter = + get_url_rewriter(url_rewriter_base, github_repository, has_readme); + + let markdown_renderer = deno_doc::html::comrak::create_renderer( + Some(Arc::new(super::tree_sitter::ComrakAdapter { + show_line_numbers: false, + })), + Some(Box::new(match_node_value)), + Some(Box::new(|html| AMMONIA.clean(&html).to_string())), + ); + + let markdown_renderer = Rc::new( + move |md: &str, + title_only: bool, + file_path: Option, + anchorizer: deno_doc::html::jsdoc::Anchorizer| { + CURRENT_FILE.set(Some(file_path)); + URL_REWRITER.set(Some(url_rewriter.clone())); + + // we pass None as we know that the comrak renderer doesnt use this option + // and as such can save a clone. careful if comrak renderer changes. + let rendered = markdown_renderer(md, title_only, None, anchorizer); + + CURRENT_FILE.set(None); + URL_REWRITER.set(None); + + rendered + }, + ); + GenerateCtx::new( deno_doc::html::GenerateOptions { package_name: Some(package_name), @@ -254,19 +509,7 @@ pub fn get_generate_ctx<'a>( disable_search: false, symbol_redirect_map: None, default_symbol_map: None, - markdown_renderer: deno_doc::html::comrak::create_renderer( - Some(Arc::new(super::tree_sitter::ComrakAdapter { - show_line_numbers: false, - })), - Some(Box::new(|ammonia| { - ammonia.add_allowed_classes("span", crate::tree_sitter::CLASSES); - })), - Some(get_url_rewriter( - url_rewriter_base, - github_repository, - has_readme, - )), - ), + markdown_renderer, markdown_stripper: Rc::new(deno_doc::html::comrak::strip), head_inject: None, }, @@ -316,11 +559,7 @@ pub fn generate_docs_html( let render_ctx = RenderContext::new(&ctx, &[], UrlResolveKind::AllSymbols); - let all_doc_nodes = ctx - .doc_nodes - .values() - .flatten() - .map(std::borrow::Cow::Borrowed); + let all_doc_nodes = ctx.doc_nodes.values().flatten().map(Cow::Borrowed); let partitions_by_kind = deno_doc::html::partition::partition_nodes_by_entrypoint( diff --git a/api/src/docs/bulb.svg b/api/src/docs/bulb.svg new file mode 100644 index 00000000..ce97591b --- /dev/null +++ b/api/src/docs/bulb.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/api/src/docs/info-circle.svg b/api/src/docs/info-circle.svg new file mode 100644 index 00000000..d5c57db7 --- /dev/null +++ b/api/src/docs/info-circle.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/api/src/docs/warning-message.svg b/api/src/docs/warning-message.svg new file mode 100644 index 00000000..7c3348ed --- /dev/null +++ b/api/src/docs/warning-message.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/api/src/docs/warning-octagon.svg b/api/src/docs/warning-octagon.svg new file mode 100644 index 00000000..83fe5b8c --- /dev/null +++ b/api/src/docs/warning-octagon.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/api/src/docs/warning-triangle.svg b/api/src/docs/warning-triangle.svg new file mode 100644 index 00000000..0a16825d --- /dev/null +++ b/api/src/docs/warning-triangle.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/frontend/deno.lock b/frontend/deno.lock index 42b4f9e9..67295a47 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "jsr:@deno/gfm@0.10": "0.10.0", "jsr:@denosaurs/emoji@0.3": "0.3.1", + "jsr:@fresh/core@^2.0.0-alpha.1": "2.0.0-alpha.25", "jsr:@fresh/core@^2.0.0-alpha.25": "2.0.0-alpha.25", "jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7": "0.0.1-alpha.7", "jsr:@luca/esbuild-deno-loader@0.11": "0.11.0", @@ -17,6 +18,7 @@ "jsr:@std/fs@1": "1.0.5", "jsr:@std/html@1": "1.0.3", "jsr:@std/http@1": "1.0.9", + "jsr:@std/json@1": "1.0.0", "jsr:@std/jsonc@1": "1.0.1", "jsr:@std/media-types@1": "1.0.3", "jsr:@std/path@0.221": "0.221.0", @@ -99,6 +101,7 @@ "@fresh/plugin-tailwind@0.0.1-alpha.7": { "integrity": "b940991bdb76f0995dc58b25183f1001d72c4020e049d384ad3fb751556aa2a9", "dependencies": [ + "jsr:@fresh/core@^2.0.0-alpha.1", "jsr:@std/path@0.221", "npm:autoprefixer", "npm:cssnano", @@ -150,8 +153,14 @@ "@std/http@1.0.9": { "integrity": "d409fc319a5e8d4a154e576c758752e9700282d74f31357a12fec6420f9ecb6c" }, + "@std/json@1.0.0": { + "integrity": "985c1e544918d42e4e84072fc739ac4a19c3a5093292c99742ffcdd03fb6a268" + }, "@std/jsonc@1.0.1": { - "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda" + "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda", + "dependencies": [ + "jsr:@std/json" + ] }, "@std/media-types@1.0.3": { "integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159"