From 53c86dd937d0984e168d6bd20460ee4be83f41bd Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 14 Nov 2024 18:18:27 -0800 Subject: [PATCH] feat: support html generation via Wasm (#658) --- Cargo.lock | 18 ++ Cargo.toml | 17 +- examples/ddoc/main.rs | 9 +- js/mod.ts | 207 ++++++++++++++++++++++- js/test.ts | 27 ++- lib/Cargo.toml | 1 + lib/lib.rs | 328 +++++++++++++++++++++++++++++++++++++ src/html/jsdoc.rs | 23 ++- src/html/mod.rs | 14 +- src/html/render_context.rs | 3 +- src/html/usage.rs | 113 +++++++++++-- src/html/util.rs | 3 +- src/lib.rs | 7 +- tests/html_test.rs | 3 +- 14 files changed, 725 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dfe9d13e..272e973a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -567,6 +567,7 @@ dependencies = [ "criterion", "deno_ast", "deno_graph", + "deno_path_util", "file_test_runner", "futures", "handlebars", @@ -575,13 +576,18 @@ dependencies = [ "indexmap 2.6.0", "insta", "itoa", + "js-sys", "lazy_static", + "percent-encoding", "pretty_assertions", "regex", "serde", + "serde-wasm-bindgen", "serde_json", "termcolor", "tokio", + "url", + "wasm-bindgen", ] [[package]] @@ -593,6 +599,7 @@ dependencies = [ "deno_doc", "deno_graph", "import_map", + "indexmap 2.6.0", "js-sys", "pretty_assertions", "serde", @@ -640,6 +647,17 @@ dependencies = [ "url", ] +[[package]] +name = "deno_path_util" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff25f6e08e7a0214bbacdd6f7195c7f1ebcd850c87a624e4ff06326b68b42d99" +dependencies = [ + "percent-encoding", + "thiserror", + "url", +] + [[package]] name = "deno_semver" version = "0.5.14" diff --git a/Cargo.toml b/Cargo.toml index eb6c8c88..ca596b44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ name = "deno_doc" [[example]] name = "ddoc" -required-features = ["html", "comrak"] +required-features = ["comrak"] [dependencies] anyhow = "1.0.86" @@ -38,9 +38,10 @@ serde.workspace = true serde_json = { version = "1.0.122", features = ["preserve_order"] } termcolor = "1.4.1" itoa = "1.0.11" +deno_path_util = "0.2.1" -html-escape = { version = "0.2.13", optional = true } -handlebars = { version = "6.1", features = ["string_helpers"], optional = true } +html-escape = { version = "0.2.13" } +handlebars = { version = "6.1", features = ["string_helpers"] } comrak = { version = "0.29.0", optional = true, default-features = false } ammonia = { version = "4.0.0", optional = true } @@ -54,10 +55,16 @@ tokio = { version = "1.39.2", features = ["full"] } pretty_assertions = "1.4.0" insta = { version = "1.39.0", features = ["json"] } +[target.'cfg(target_arch = "wasm32")'.dependencies] +url = "2.5.2" +percent-encoding = "2.3.1" +wasm-bindgen = "0.2.92" +js-sys = "0.3.69" +serde-wasm-bindgen = "=0.5.0" + [features] -default = ["rust", "html", "comrak"] +default = ["rust", "comrak"] rust = [] -html = ["html-escape", "handlebars"] comrak = ["dep:comrak", "ammonia"] [[test]] diff --git a/examples/ddoc/main.rs b/examples/ddoc/main.rs index 32d3bad6..4c850c7f 100644 --- a/examples/ddoc/main.rs +++ b/examples/ddoc/main.rs @@ -3,10 +3,10 @@ use clap::App; use clap::Arg; use deno_doc::find_nodes_by_name_recursively; +use deno_doc::html::HrefResolver; use deno_doc::html::UrlResolveKind; -use deno_doc::html::{ - DocNodeWithContext, HrefResolver, UsageComposer, UsageComposerEntry, -}; +use deno_doc::html::UsageComposer; +use deno_doc::html::UsageComposerEntry; use deno_doc::DocNodeKind; use deno_doc::DocParser; use deno_doc::DocParserOptions; @@ -216,7 +216,6 @@ impl UsageComposer for EmptyResolver { fn compose( &self, - nodes: &[DocNodeWithContext], current_resolve: UrlResolveKind, usage_to_md: deno_doc::html::UsageToMd, ) -> IndexMap { @@ -228,7 +227,7 @@ impl UsageComposer for EmptyResolver { name: "".to_string(), icon: None, }, - usage_to_md(nodes, current_file.specifier.as_str(), None), + usage_to_md(current_file.specifier.as_str(), None), )]) }) .unwrap_or_default() diff --git a/js/mod.ts b/js/mod.ts index 28997aea..e8965937 100644 --- a/js/mod.ts +++ b/js/mod.ts @@ -1,7 +1,7 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { instantiate } from "./deno_doc_wasm.generated.js"; -import type { DocNode } from "./types.d.ts"; +import type { DocNode, Location } from "./types.d.ts"; import { createCache } from "jsr:@deno/cache-dir@0.11"; import type { CacheSetting, LoadResponse } from "jsr:@deno/graph@0.82"; @@ -124,3 +124,208 @@ export async function doc( printImportMapDiagnostics, ); } + +export interface ShortPath { + /** Name identifier for the path. */ + path: string; + /** URL for the path. */ + specifier: string; + /** Whether the path is the main entrypoint. */ + isMain: boolean; +} + +export interface UrlResolveKindRoot { + kind: "root"; +} + +export interface UrlResolveKindAllSymbols { + kind: "allSymbols"; +} + +export interface UrlResolveKindCategory { + kind: "category"; + category: string; +} + +export interface UrlResolveKindFile { + kind: "file"; + file: ShortPath; +} + +export interface UrlResolveKindSymbol { + kind: "symbol"; + file: ShortPath; + symbol: string; +} + +export type UrlResolveKind = + | UrlResolveKindRoot + | UrlResolveKindAllSymbols + | UrlResolveKindCategory + | UrlResolveKindFile + | UrlResolveKindSymbol; + +interface HrefResolver { + /** Resolver for how files should link to eachother. */ + resolvePath?(current: UrlResolveKind, target: UrlResolveKind): string; + /** Resolver for global symbols, like the Deno namespace or other built-ins */ + resolveGlobalSymbol?(symbol: string[]): string | undefined; + /** Resolver for symbols from non-relative imports */ + resolveImportHref?(symbol: string[], src: string): string | undefined; + /** Resolve the URL used in source code link buttons. */ + resolveSource?(location: Location): string | undefined; + /** + * Resolve external JSDoc module links. + * Returns a tuple with link and title. + */ + resolveExternalJsdocModule?( + module: string, + symbol?: string, + ): { link: string; title: string } | undefined; +} + +export interface UsageComposerEntry { + /** Name for the entry. Can be left blank in singleMode. */ + name: string; + /** Icon for the entry. */ + icon?: string; +} + +export type UsageToMd = ( + url: string, + customFileIdentifier: string | undefined, +) => string; + +export interface UsageComposer { + /** Whether the usage should only display a single item and not have a dropdown. */ + singleMode: boolean; + + /** + * Composer to generate usage. + * + * @param currentResolve The current resolve. + * @param usageToMd Callback to generate a usage import block. + */ + compose( + currentResolve: UrlResolveKind, + usageToMd: UsageToMd, + ): Map; +} + +interface GenerateOptions { + /** The name of the package to use in the breadcrumbs. */ + packageName?: string; + /** The main entrypoint if one is present. */ + mainEntrypoint?: string; + /** Composer for generating the usage of a symbol of module. */ + usageComposer?: UsageComposer; + /** Resolver for how links should be resolved. */ + hrefResolver?: HrefResolver; + /** Map for remapping module names to a custom value. */ + rewriteMap?: Record; + /** + * Map of categories to their markdown description. + * Only usable in category mode (single d.ts file with categories declared). + */ + categoryDocs?: Record; + /** Whether to disable search. */ + disableSearch?: boolean; + /** + * Map of modules, where the value is a map of symbols with value of a link to + * where this symbol should redirect to. + */ + symbolRedirectMap?: Record>; + /** + * Map of modules, where the value is a link to where the default symbol + * should redirect to. + */ + defaultRedirectMap?: Record; + /** + * Hook to inject content in the `head` tag. + * + * @param root the path to the root of the output. + */ + headInject?(root: string): string; + /** + * Function to render markdown. + * + * @param md The raw markdown that needs to be rendered. + * @param titleOnly Whether only the title should be rendered. Recommended syntax to keep is: + * - paragraph + * - heading + * - text + * - code + * - html inline + * - emph + * - strong + * - strikethrough + * - superscript + * - link + * - math + * - escaped + * - wiki link + * - underline + * - soft break + * @param filePath The filepath where the rendering is happening. + * @param anchorizer Anchorizer used to generate slugs and the sidebar. + * @return The rendered markdown. + */ + markdownRenderer( + md: string, + titleOnly: boolean, + filePath: ShortPath | undefined, + anchorizer: (content: string, depthLevel: number) => string, + ): string | undefined; + /** Function to strip markdown. */ + markdownStripper(md: string): string; +} + +const defaultUsageComposer: UsageComposer = { + singleMode: true, + compose(currentResolve, usageToMd) { + if ("file" in currentResolve) { + return new Map([[ + { name: "" }, + usageToMd(currentResolve.file.specifier, undefined), + ]]); + } else { + return new Map(); + } + }, +}; + +/** + * Generate HTML files for provided {@linkcode DocNode}s. + * @param options Options for the generation. + * @param docNodesByUrl DocNodes keyed by their absolute URL. + */ +export async function generateHtml( + options: GenerateOptions, + docNodesByUrl: Record>, +): Promise> { + const { + usageComposer = defaultUsageComposer, + } = options; + + const wasm = await instantiate(); + return wasm.generate_html( + options.packageName, + options.mainEntrypoint, + usageComposer.singleMode, + usageComposer.compose, + options.rewriteMap, + options.categoryDocs, + options.disableSearch ?? false, + options.symbolRedirectMap, + options.defaultRedirectMap, + options.hrefResolver?.resolvePath, + options.hrefResolver?.resolveGlobalSymbol || (() => undefined), + options.hrefResolver?.resolveImportHref || (() => undefined), + options.hrefResolver?.resolveSource || (() => undefined), + options.hrefResolver?.resolveExternalJsdocModule || (() => undefined), + options.markdownRenderer, + options.markdownStripper, + options.headInject, + docNodesByUrl, + ); +} diff --git a/js/test.ts b/js/test.ts index 0c8328fc..5c997370 100644 --- a/js/test.ts +++ b/js/test.ts @@ -1,7 +1,7 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { assert, assertEquals, assertRejects } from "jsr:@std/assert@0.223"; -import { doc } from "./mod.ts"; +import { doc, generateHtml } from "./mod.ts"; Deno.test({ name: "doc()", @@ -123,3 +123,28 @@ Deno.test({ assertEquals(entries[0].name, "B"); }, }); + +Deno.test({ + name: "generateHtml()", + async fn() { + const entries = await doc( + "https://deno.land/std@0.104.0/fmt/colors.ts", + ); + + const files = await generateHtml({ + markdownRenderer( + md, + _titleOnly, + _filePath, + _anchorizer, + ) { + return md; + }, + markdownStripper(md: string) { + return md; + }, + }, { ["file:///colors.ts"]: entries }); + + assertEquals(Object.keys(files).length, 61); + }, +}); diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 874205c3..a6ddd1c3 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -19,6 +19,7 @@ deno_graph = { workspace = true } deno_doc = { path = "../", default-features = false } import_map.workspace = true serde.workspace = true +indexmap = "2.6.0" console_error_panic_hook = "0.1.7" js-sys = "=0.3.69" diff --git a/lib/lib.rs b/lib/lib.rs index a6ad48dd..67933234 100644 --- a/lib/lib.rs +++ b/lib/lib.rs @@ -2,6 +2,9 @@ use anyhow::anyhow; use anyhow::Context; +use deno_doc::html::UrlResolveKind; +use deno_doc::html::UsageComposerEntry; +use deno_doc::html::UsageToMd; use deno_doc::DocParser; use deno_graph::source::CacheSetting; use deno_graph::source::LoadFuture; @@ -18,7 +21,9 @@ use deno_graph::ModuleSpecifier; use deno_graph::Range; use import_map::ImportMap; use import_map::ImportMapOptions; +use indexmap::IndexMap; use serde::Serialize; +use std::rc::Rc; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; @@ -243,3 +248,326 @@ async fn inner_doc( serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); Ok(entries.serialize(&serializer).unwrap()) } + +#[allow(clippy::too_many_arguments)] +#[wasm_bindgen] +pub fn generate_html( + package_name: Option, + main_entrypoint: Option, + + usage_composer_single_mode: bool, + usage_composer_compose: js_sys::Function, + + rewrite_map: JsValue, + category_docs: JsValue, + disable_search: bool, + symbol_redirect_map: JsValue, + default_symbol_map: JsValue, + + resolve_path: Option, + resolve_global_symbol: js_sys::Function, + resolve_import_href: js_sys::Function, + resolve_source: js_sys::Function, + resolve_external_jsdoc_module: js_sys::Function, + + markdown_renderer: js_sys::Function, + markdown_stripper: js_sys::Function, + head_inject: Option, + + doc_nodes_by_url: JsValue, +) -> Result { + console_error_panic_hook::set_once(); + + generate_html_inner( + package_name, + main_entrypoint, + usage_composer_single_mode, + usage_composer_compose, + rewrite_map, + category_docs, + disable_search, + symbol_redirect_map, + default_symbol_map, + resolve_path, + resolve_global_symbol, + resolve_import_href, + resolve_source, + resolve_external_jsdoc_module, + markdown_renderer, + markdown_stripper, + head_inject, + doc_nodes_by_url, + ) + .map_err(|err| JsValue::from(js_sys::Error::new(&err.to_string()))) +} + +struct JsHrefResolver { + resolve_path: Option, + resolve_global_symbol: js_sys::Function, + resolve_import_href: js_sys::Function, + resolve_source: js_sys::Function, + resolve_external_jsdoc_module: js_sys::Function, +} + +impl deno_doc::html::HrefResolver for JsHrefResolver { + fn resolve_path( + &self, + current: UrlResolveKind, + target: UrlResolveKind, + ) -> String { + if let Some(resolve_path) = &self.resolve_path { + let this = JsValue::null(); + + let current = serde_wasm_bindgen::to_value(¤t).unwrap(); + let target = serde_wasm_bindgen::to_value(&target).unwrap(); + + let global_symbol = resolve_path + .call2(&this, ¤t, &target) + .expect("resolve_path errored"); + + serde_wasm_bindgen::from_value(global_symbol) + .expect("resolve_path returned an invalid value") + } else { + deno_doc::html::href_path_resolve(current, target) + } + } + + fn resolve_global_symbol(&self, symbol: &[String]) -> Option { + let this = JsValue::null(); + + let symbol = serde_wasm_bindgen::to_value(&symbol).unwrap(); + + let global_symbol = self + .resolve_global_symbol + .call1(&this, &symbol) + .expect("resolve_global_symbol errored"); + + serde_wasm_bindgen::from_value(global_symbol) + .expect("resolve_global_symbol returned an invalid value") + } + + fn resolve_import_href( + &self, + symbol: &[String], + src: &str, + ) -> Option { + let this = JsValue::null(); + + let symbol = serde_wasm_bindgen::to_value(&symbol).unwrap(); + let src = serde_wasm_bindgen::to_value(&src).unwrap(); + + let global_symbol = self + .resolve_import_href + .call2(&this, &symbol, &src) + .expect("resolve_import_href errored"); + + serde_wasm_bindgen::from_value(global_symbol) + .expect("resolve_import_href returned an invalid value") + } + + fn resolve_source(&self, location: &deno_doc::Location) -> Option { + let this = JsValue::null(); + + let location = serde_wasm_bindgen::to_value(&location).unwrap(); + + let global_symbol = self + .resolve_source + .call1(&this, &location) + .expect("resolve_source errored"); + + serde_wasm_bindgen::from_value(global_symbol) + .expect("resolve_source returned an invalid value") + } + + fn resolve_external_jsdoc_module( + &self, + module: &str, + symbol: Option<&str>, + ) -> Option<(String, String)> { + let this = JsValue::null(); + + let module = serde_wasm_bindgen::to_value(&module).unwrap(); + let symbol = serde_wasm_bindgen::to_value(&symbol).unwrap(); + + let global_symbol = self + .resolve_external_jsdoc_module + .call2(&this, &module, &symbol) + .expect("resolve_external_jsdoc_module errored"); + + serde_wasm_bindgen::from_value(global_symbol) + .expect("resolve_external_jsdoc_module returned an invalid value") + } +} + +struct JsUsageComposer { + single_mode: bool, + compose: js_sys::Function, +} + +impl deno_doc::html::UsageComposer for JsUsageComposer { + fn is_single_mode(&self) -> bool { + self.single_mode + } + + fn compose( + &self, + current_resolve: UrlResolveKind, + usage_to_md: UsageToMd, + ) -> IndexMap { + let this = JsValue::null(); + + let current_resolve = + serde_wasm_bindgen::to_value(¤t_resolve).unwrap(); + + let global_symbol = self + .compose + .call2(&this, ¤t_resolve, &usage_to_md) + .expect("compose errored"); + + serde_wasm_bindgen::from_value(global_symbol) + .expect("compose returned an invalid value") + } +} + +#[allow(clippy::too_many_arguments)] +fn generate_html_inner( + package_name: Option, + main_entrypoint: Option, + + usage_composer_single_mode: bool, + usage_composer_compose: js_sys::Function, + + rewrite_map: JsValue, + category_docs: JsValue, + disable_search: bool, + symbol_redirect_map: JsValue, + default_symbol_map: JsValue, + + resolve_path: Option, + resolve_global_symbol: js_sys::Function, + resolve_import_href: js_sys::Function, + resolve_source: js_sys::Function, + resolve_external_jsdoc_module: js_sys::Function, + + markdown_renderer: js_sys::Function, + markdown_stripper: js_sys::Function, + head_inject: Option, + + doc_nodes_by_url: JsValue, +) -> Result { + let main_entrypoint = main_entrypoint + .map(|s| ModuleSpecifier::parse(&s)) + .transpose() + .map_err(|e| anyhow::Error::from(e).context("mainEntrypoint"))?; + + let rewrite_map = serde_wasm_bindgen::from_value::< + Option>, + >(rewrite_map) + .map_err(|err| anyhow!("rewriteMap: {}", err))?; + + let category_docs = serde_wasm_bindgen::from_value::< + Option>>, + >(category_docs) + .map_err(|err| anyhow!("categoryDocs: {}", err))?; + + let symbol_redirect_map = serde_wasm_bindgen::from_value::< + Option>>, + >(symbol_redirect_map) + .map_err(|err| anyhow!("symbolRedirectMap: {}", err))?; + + let default_symbol_map = serde_wasm_bindgen::from_value::< + Option>, + >(default_symbol_map) + .map_err(|err| anyhow!("defaultSymbolMap: {}", err))?; + + let doc_nodes_by_url: IndexMap> = + serde_wasm_bindgen::from_value(doc_nodes_by_url) + .map_err(|err| anyhow!("docNodesByUrl: {}", err))?; + + let markdown_renderer = Rc::new( + move |md: &str, + title_only: bool, + file_path: Option, + anchorizer: deno_doc::html::jsdoc::Anchorizer| { + let this = JsValue::null(); + let md = serde_wasm_bindgen::to_value(md).unwrap(); + let title_only = serde_wasm_bindgen::to_value(&title_only).unwrap(); + let file_path = serde_wasm_bindgen::to_value(&file_path).unwrap(); + + let html = markdown_renderer + .apply( + &this, + &js_sys::Array::of4(&md, &title_only, &file_path, &anchorizer), + ) + .expect("markdown_renderer errored"); + + serde_wasm_bindgen::from_value(html) + .expect("markdown_renderer returned an invalid value") + }, + ); + + let markdown_stripper = Rc::new(move |md: &str| { + let this = JsValue::null(); + let md = serde_wasm_bindgen::to_value(md).unwrap(); + + let stripped = markdown_stripper + .call1(&this, &md) + .expect("markdown_stripper errored"); + + serde_wasm_bindgen::from_value(stripped) + .expect("markdown_stripper returned an invalid value") + }); + + let head_inject: Option String + 'static>> = + if let Some(head_inject) = head_inject { + let head_inject = Rc::new(move |root: &str| { + let this = JsValue::null(); + let root = serde_wasm_bindgen::to_value(root).unwrap(); + + let inject = head_inject + .call1(&this, &root) + .expect("head_inject errored"); + + serde_wasm_bindgen::from_value::(inject) + .expect("head_inject returned an invalid value") + }); + + Some(head_inject) + } else { + None + }; + + let files = deno_doc::html::generate( + deno_doc::html::GenerateOptions { + package_name, + main_entrypoint, + href_resolver: Rc::new(JsHrefResolver { + resolve_path, + resolve_global_symbol, + resolve_import_href, + resolve_source, + resolve_external_jsdoc_module, + }), + usage_composer: Rc::new(JsUsageComposer { + single_mode: usage_composer_single_mode, + compose: usage_composer_compose, + }), + rewrite_map, + category_docs, + disable_search, + symbol_redirect_map, + default_symbol_map, + markdown_renderer, + markdown_stripper, + head_inject, + }, + doc_nodes_by_url, + )?; + + let serializer = + serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + + files + .serialize(&serializer) + .map_err(|err| anyhow!("{}", err)) +} diff --git a/src/html/jsdoc.rs b/src/html/jsdoc.rs index 54570bd3..8f30f068 100644 --- a/src/html/jsdoc.rs +++ b/src/html/jsdoc.rs @@ -6,6 +6,7 @@ use crate::js_doc::JsDocTag; use crate::DocNodeKind; use serde::Serialize; use std::borrow::Cow; +use std::rc::Rc; lazy_static! { static ref JSDOC_LINK_RE: regex::Regex = regex::Regex::new( @@ -153,12 +154,14 @@ pub fn strip(render_ctx: &RenderContext, md: &str) -> String { (render_ctx.ctx.markdown_stripper)(&md) } +#[cfg(not(feature = "rust"))] +pub type Anchorizer<'a> = &'a js_sys::Function; +#[cfg(feature = "rust")] pub type Anchorizer = std::sync::Arc String + Send + Sync>; -pub type MarkdownRenderer = std::rc::Rc< - dyn (Fn(&str, bool, Option, Anchorizer) -> Option), ->; +pub type MarkdownRenderer = + Rc, Anchorizer) -> Option)>; pub fn markdown_to_html( render_ctx: &RenderContext, @@ -185,8 +188,18 @@ pub fn markdown_to_html( anchor }; + #[cfg(not(target_arch = "wasm32"))] let anchorizer = std::sync::Arc::new(anchorizer); + #[cfg(target_arch = "wasm32")] + let anchorizer = wasm_bindgen::prelude::Closure::wrap( + Box::new(anchorizer) as Box String> + ); + #[cfg(target_arch = "wasm32")] + let anchorizer = wasm_bindgen::JsCast::unchecked_ref::( + anchorizer.as_ref(), + ); + let md = parse_links(md, render_ctx); let file = render_ctx.get_current_resolve().get_file().cloned(); @@ -379,7 +392,6 @@ impl ModuleDocCtx { mod test { use crate::html::href_path_resolve; use crate::html::jsdoc::parse_links; - use crate::html::DocNodeWithContext; use crate::html::GenerateCtx; use crate::html::GenerateOptions; use crate::html::HrefResolver; @@ -441,7 +453,6 @@ mod test { fn compose( &self, - nodes: &[DocNodeWithContext], current_resolve: UrlResolveKind, usage_to_md: UsageToMd, ) -> IndexMap { @@ -453,7 +464,7 @@ mod test { name: "".to_string(), icon: None, }, - usage_to_md(nodes, current_file.display_name(), None), + usage_to_md(current_file.display_name(), None), )]) }) .unwrap_or_default() diff --git a/src/html/mod.rs b/src/html/mod.rs index a11b40bb..3fe9b2b9 100644 --- a/src/html/mod.rs +++ b/src/html/mod.rs @@ -5,6 +5,8 @@ use deno_ast::ModuleSpecifier; use handlebars::handlebars_helper; use handlebars::Handlebars; use indexmap::IndexMap; +use serde::Deserialize; +use serde::Serialize; use std::borrow::Cow; use std::cmp::Ordering; use std::collections::HashMap; @@ -408,7 +410,8 @@ impl GenerateCtx { } } -#[derive(Clone, Debug, Eq, PartialEq, Hash)] +#[derive(Clone, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct ShortPath { pub path: String, pub specifier: ModuleSpecifier, @@ -440,7 +443,7 @@ impl ShortPath { }; } - let Ok(url_file_path) = specifier.to_file_path() else { + let Ok(url_file_path) = deno_path_util::url_to_file_path(&specifier) else { return ShortPath { path: specifier.to_string(), specifier, @@ -510,7 +513,7 @@ impl PartialOrd for ShortPath { } } -#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize)] pub enum DocNodeKindWithDrilldown { Property, Method(MethodKind), @@ -569,7 +572,8 @@ impl Ord for DocNodeKindWithDrilldown { /// A wrapper around [`DocNode`] with additional fields to track information /// about the inner [`DocNode`]. /// This is cheap to clone since all fields are [`Rc`]s. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct DocNodeWithContext { pub origin: Rc, pub ns_qualifiers: Rc<[String]>, @@ -1017,7 +1021,7 @@ pub fn find_common_ancestor<'a>( let paths: Vec = urls .filter_map(|url| { if url.scheme() == "file" { - url.to_file_path().ok() + deno_path_util::url_to_file_path(url).ok() } else { None } diff --git a/src/html/render_context.rs b/src/html/render_context.rs index a6534436..0c3bc8d7 100644 --- a/src/html/render_context.rs +++ b/src/html/render_context.rs @@ -543,7 +543,6 @@ mod test { fn compose( &self, - doc_nodes: &[DocNodeWithContext], current_resolve: UrlResolveKind, usage_to_md: UsageToMd, ) -> IndexMap { @@ -555,7 +554,7 @@ mod test { name: "".to_string(), icon: None, }, - usage_to_md(doc_nodes, current_file.specifier.as_str(), None), + usage_to_md(current_file.specifier.as_str(), None), )]) }) .unwrap_or_default() diff --git a/src/html/usage.rs b/src/html/usage.rs index 7f0d7cdf..df434c91 100644 --- a/src/html/usage.rs +++ b/src/html/usage.rs @@ -9,6 +9,17 @@ use regex::Regex; use serde::Serialize; use std::borrow::Cow; +#[cfg(target_arch = "wasm32")] +use std::cell::RefCell; +#[cfg(target_arch = "wasm32")] +use std::ffi::c_void; + +#[cfg(target_arch = "wasm32")] +thread_local! { + static RENDER_CONTEXT: RefCell<*const c_void> = const { RefCell::new(std::ptr::null()) }; + static DOC_NODES: RefCell<(*const c_void, usize)> = const { RefCell::new((std::ptr::null(), 0)) }; +} + lazy_static! { static ref IDENTIFIER_RE: Regex = Regex::new(r"[^a-zA-Z$_]").unwrap(); } @@ -171,8 +182,10 @@ fn get_identifier_for_file( ) } -pub type UsageToMd<'a> = - &'a dyn Fn(&[DocNodeWithContext], &str, Option<&str>) -> String; +#[cfg(not(feature = "rust"))] +pub type UsageToMd<'a> = &'a js_sys::Function; +#[cfg(feature = "rust")] +pub type UsageToMd<'a> = &'a dyn Fn(&str, Option<&str>) -> String; #[derive(Clone, Debug, Serialize)] struct UsageCtx { @@ -201,19 +214,92 @@ impl UsagesCtx { return None; } - let usage_ctx = ctx.clone(); + #[cfg(not(target_arch = "wasm32"))] let usage_to_md_closure = - move |nodes: &[DocNodeWithContext], - url: &str, - custom_file_identifier: Option<&str>| { - usage_to_md(&usage_ctx, nodes, url, custom_file_identifier) + move |url: &str, custom_file_identifier: Option<&str>| { + usage_to_md(ctx, doc_nodes, url, custom_file_identifier) + }; + + #[cfg(target_arch = "wasm32")] + { + let ctx_ptr = ctx as *const RenderContext as *const c_void; + RENDER_CONTEXT.set(ctx_ptr); + let nodes_ptr = doc_nodes as *const [DocNodeWithContext] as *const c_void; + DOC_NODES.set((nodes_ptr, doc_nodes.len())); + } + + #[cfg(target_arch = "wasm32")] + let usage_to_md_closure = + move |url: String, custom_file_identifier: Option| { + RENDER_CONTEXT.with(|ctx| { + let render_ctx_ptr = *ctx.borrow() as *const RenderContext; + // SAFETY: this pointer is valid until destroyed, which is done + // after compose is called + let render_ctx = unsafe { &*render_ctx_ptr }; + + let usage = DOC_NODES.with(|nodes| { + let (nodes_ptr, nodes_ptr_len) = *nodes.borrow(); + // SAFETY: the pointers are valid until destroyed, which is done + // after compose is called + let doc_nodes = unsafe { + std::slice::from_raw_parts( + nodes_ptr as *const DocNodeWithContext, + nodes_ptr_len, + ) + }; + + let usage = usage_to_md( + &render_ctx, + doc_nodes, + &url, + custom_file_identifier.as_deref(), + ); + + *nodes.borrow_mut() = ( + doc_nodes as *const [DocNodeWithContext] as *const c_void, + doc_nodes.len(), + ); + + usage + }); + + *ctx.borrow_mut() = + render_ctx as *const RenderContext as *const c_void; + usage + }) }; - let usages = ctx.ctx.usage_composer.compose( - doc_nodes, - ctx.get_current_resolve(), - &usage_to_md_closure, - ); + #[cfg(target_arch = "wasm32")] + let usage_to_md_closure = + wasm_bindgen::prelude::Closure::wrap(Box::new(usage_to_md_closure) + as Box) -> String>); + #[cfg(target_arch = "wasm32")] + let usage_to_md_closure = &wasm_bindgen::JsCast::unchecked_ref::< + js_sys::Function, + >(usage_to_md_closure.as_ref()); + + let usages = ctx + .ctx + .usage_composer + .compose(ctx.get_current_resolve(), &usage_to_md_closure); + + #[cfg(target_arch = "wasm32")] + { + let render_ctx = + RENDER_CONTEXT.replace(std::ptr::null()) as *const RenderContext; + // SAFETY: take the pointer and drop it + let _ = unsafe { &*render_ctx }; + + let (doc_nodes_ptr, doc_nodes_ptr_len) = + DOC_NODES.replace((std::ptr::null(), 0)); + // SAFETY: take the pointer and drop it + let _ = unsafe { + std::slice::from_raw_parts( + doc_nodes_ptr as *const DocNodeWithContext, + doc_nodes_ptr_len, + ) + }; + }; if usages.is_empty() { None @@ -240,7 +326,7 @@ impl UsagesCtx { } } -#[derive(Eq, PartialEq, Hash)] +#[derive(Eq, PartialEq, Hash, serde::Deserialize)] pub struct UsageComposerEntry { pub name: String, pub icon: Option>, @@ -251,7 +337,6 @@ pub trait UsageComposer { fn compose( &self, - doc_nodes: &[DocNodeWithContext], current_resolve: UrlResolveKind, usage_to_md: UsageToMd, ) -> IndexMap; diff --git a/src/html/util.rs b/src/html/util.rs index 285287f0..89a51a8f 100644 --- a/src/html/util.rs +++ b/src/html/util.rs @@ -227,7 +227,8 @@ impl NamespacedGlobalSymbols { } /// Different current and target locations -#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] pub enum UrlResolveKind<'a> { Root, AllSymbols, diff --git a/src/lib.rs b/src/lib.rs index 958cee8c..ccc1bce4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ mod diagnostics; mod display; pub mod r#enum; pub mod function; +pub mod html; pub mod interface; pub mod js_doc; pub mod node; @@ -54,12 +55,6 @@ pub use parser::DocError; pub use parser::DocParser; pub use parser::DocParserOptions; -cfg_if! { - if #[cfg(feature = "html")] { - pub mod html; - } -} - #[cfg(test)] mod tests; diff --git a/tests/html_test.rs b/tests/html_test.rs index 51814534..ac9d7edc 100644 --- a/tests/html_test.rs +++ b/tests/html_test.rs @@ -88,7 +88,6 @@ impl UsageComposer for EmptyResolver { fn compose( &self, - doc_nodes: &[DocNodeWithContext], current_resolve: UrlResolveKind, usage_to_md: UsageToMd, ) -> IndexMap { @@ -100,7 +99,7 @@ impl UsageComposer for EmptyResolver { name: "".to_string(), icon: None, }, - usage_to_md(doc_nodes, current_file.path.as_str(), None), + usage_to_md(current_file.path.as_str(), None), )]) }) .unwrap_or_default()