deno_doc/html/
jsdoc.rs

1use super::render_context::RenderContext;
2use super::util::*;
3use crate::html::ShortPath;
4use crate::js_doc::JsDoc;
5use crate::js_doc::JsDocTag;
6use crate::node::DocNodeDef;
7use serde::Serialize;
8use std::borrow::Cow;
9use std::rc::Rc;
10
11lazy_static! {
12  static ref JSDOC_LINK_RE: regex::Regex = regex::Regex::new(
13    r"(?m)\{\s*@link(?P<modifier>code|plain)?\s+(?P<value>[^}]+)}"
14  )
15  .unwrap();
16  static ref LINK_RE: regex::Regex =
17    regex::Regex::new(r"(^\.{0,2}\/)|(^[A-Za-z]+:\S)").unwrap();
18  static ref MODULE_LINK_RE: regex::Regex =
19    regex::Regex::new(r"^\[(\S+)\](?:\.(\S+)|\s|)$").unwrap();
20}
21
22fn parse_links<'a>(
23  md: &'a str,
24  ctx: &RenderContext,
25  strip: bool,
26) -> Cow<'a, str> {
27  JSDOC_LINK_RE.replace_all(md, |captures: &regex::Captures| {
28    let code = captures
29      .name("modifier")
30      .map_or("plain", |modifier_match| modifier_match.as_str())
31      == "code";
32    let value = captures.name("value").unwrap().as_str();
33
34    let (link, mut title) = if let Some((link, title)) =
35      value.split_once('|').or_else(|| value.split_once(' '))
36    {
37      (link.trim(), title.trim().to_string())
38    } else {
39      (value, "".to_string())
40    };
41
42    let link = if let Some(module_link_captures) = MODULE_LINK_RE.captures(link)
43    {
44      let module_match = module_link_captures.get(1).unwrap();
45      let module_link = module_match.as_str();
46      let symbol_match = module_link_captures.get(2);
47
48      let mut link = link.to_string();
49
50      let module = ctx.ctx.doc_nodes.iter().find(|(short_path, _)| {
51        short_path.path == module_link
52          || short_path.display_name() == module_link
53      });
54
55      if let Some((short_path, nodes)) = module {
56        if let Some(symbol_match) = symbol_match {
57          if nodes
58            .iter()
59            .any(|node| node.get_qualified_name() == symbol_match.as_str())
60          {
61            link = ctx.ctx.resolve_path(
62              ctx.get_current_resolve(),
63              UrlResolveKind::Symbol {
64                file: short_path,
65                symbol: symbol_match.as_str(),
66              },
67            );
68            if title.is_empty() {
69              title = format!(
70                "{} {}",
71                short_path.display_name(),
72                symbol_match.as_str()
73              );
74            }
75          }
76        } else {
77          link = ctx.ctx.resolve_path(
78            ctx.get_current_resolve(),
79            short_path.as_resolve_kind(),
80          );
81          if title.is_empty() {
82            title = short_path.display_name().to_string();
83          }
84        }
85      } else if let Some((external_link, external_title)) =
86        ctx.ctx.href_resolver.resolve_external_jsdoc_module(
87          module_link,
88          symbol_match.map(|symbol_match| symbol_match.as_str()),
89        )
90      {
91        link = external_link;
92        title = external_title;
93      }
94
95      link
96    } else {
97      link.to_string()
98    };
99
100    let (title, link) = if let Some(href) = ctx.lookup_symbol_href(&link) {
101      let title = if title.is_empty() {
102        link
103      } else {
104        title.to_string()
105      };
106
107      (title, href)
108    } else {
109      let title = if title.is_empty() {
110        link.clone()
111      } else {
112        title.to_string()
113      };
114
115      (title, link)
116    };
117
118    if strip {
119      title
120    } else if LINK_RE.is_match(&link) {
121      if code {
122        format!("[`{title}`]({link})")
123      } else {
124        format!("[{title}]({link})")
125      }
126    } else {
127      #[allow(clippy::collapsible_if)]
128      if code {
129        format!("`{title}`")
130      } else {
131        title
132      }
133    }
134  })
135}
136
137fn split_markdown_title(md: &str) -> (Option<&str>, Option<&str>) {
138  let newline = md.find("\n\n").unwrap_or(usize::MAX);
139  let codeblock = md.find("```").unwrap_or(usize::MAX);
140
141  let index = newline.min(codeblock).min(md.len());
142
143  match md.split_at(index) {
144    ("", body) => (None, Some(body)),
145    (title, "") => (None, Some(title)),
146    (title, body) => (Some(title), Some(body)),
147  }
148}
149
150pub struct MarkdownToHTMLOptions {
151  pub title_only: bool,
152  pub no_toc: bool,
153}
154
155pub type MarkdownStripper = std::rc::Rc<dyn (Fn(&str) -> String)>;
156
157pub fn strip(render_ctx: &RenderContext, md: &str) -> String {
158  let md = parse_links(md, render_ctx, true);
159
160  (render_ctx.ctx.markdown_stripper)(&md)
161}
162
163#[cfg(not(feature = "rust"))]
164pub type Anchorizer<'a> = &'a js_sys::Function;
165#[cfg(feature = "rust")]
166pub type Anchorizer =
167  std::sync::Arc<dyn Fn(String, u8) -> String + Send + Sync>;
168
169pub type MarkdownRenderer =
170  Rc<dyn (Fn(&str, bool, Option<ShortPath>, Anchorizer) -> Option<String>)>;
171
172pub fn markdown_to_html(
173  render_ctx: &RenderContext,
174  md: &str,
175  render_options: MarkdownToHTMLOptions,
176) -> Option<String> {
177  let toc = render_ctx.toc.clone();
178
179  let anchorizer = move |content: String, level: u8| {
180    let mut anchorizer = toc.anchorizer.lock().unwrap();
181    let offset = toc.offset.lock().unwrap();
182
183    let anchor = anchorizer.anchorize(&content);
184
185    if !render_options.no_toc {
186      let mut toc = toc.toc.lock().unwrap();
187      toc.push(crate::html::render_context::ToCEntry {
188        level: level + *offset,
189        content,
190        anchor: anchor.clone(),
191      });
192    }
193
194    anchor
195  };
196
197  #[cfg(not(target_arch = "wasm32"))]
198  let anchorizer = std::sync::Arc::new(anchorizer);
199
200  #[cfg(target_arch = "wasm32")]
201  let anchorizer = wasm_bindgen::prelude::Closure::wrap(
202    Box::new(anchorizer) as Box<dyn Fn(String, u8) -> String>
203  );
204  #[cfg(target_arch = "wasm32")]
205  let anchorizer = wasm_bindgen::JsCast::unchecked_ref::<js_sys::Function>(
206    anchorizer.as_ref(),
207  );
208
209  let md = parse_links(md, render_ctx, false);
210
211  let file = render_ctx.get_current_resolve().get_file().cloned();
212
213  (render_ctx.ctx.markdown_renderer)(
214    &md,
215    render_options.title_only,
216    file,
217    anchorizer,
218  )
219}
220
221pub(crate) fn render_markdown(
222  render_ctx: &RenderContext,
223  md: &str,
224  no_toc: bool,
225) -> String {
226  markdown_to_html(
227    render_ctx,
228    md,
229    MarkdownToHTMLOptions {
230      title_only: false,
231      no_toc,
232    },
233  )
234  .unwrap_or_default()
235}
236
237pub(crate) fn jsdoc_body_to_html(
238  ctx: &RenderContext,
239  js_doc: &JsDoc,
240  summary: bool,
241) -> Option<String> {
242  if let Some(doc) = js_doc.doc.as_deref() {
243    markdown_to_html(
244      ctx,
245      doc,
246      MarkdownToHTMLOptions {
247        title_only: summary,
248        no_toc: false,
249      },
250    )
251  } else {
252    None
253  }
254}
255
256pub(crate) fn jsdoc_examples(
257  ctx: &RenderContext,
258  js_doc: &JsDoc,
259) -> Option<SectionCtx> {
260  let mut i = 0;
261
262  let examples = js_doc
263    .tags
264    .iter()
265    .filter_map(|tag| {
266      if let JsDocTag::Example { doc } = tag {
267        let example = ExampleCtx::new(ctx, doc, i);
268        i += 1;
269        Some(example)
270      } else {
271        None
272      }
273    })
274    .collect::<Vec<ExampleCtx>>();
275
276  if !examples.is_empty() {
277    Some(SectionCtx::new(
278      ctx,
279      "Examples",
280      SectionContentCtx::Example(examples),
281    ))
282  } else {
283    None
284  }
285}
286
287#[derive(Debug, Serialize, Clone)]
288pub struct ExampleCtx {
289  pub anchor: AnchorCtx,
290  pub id: Id,
291  pub title: String,
292  pub markdown_title: String,
293  markdown_body: String,
294}
295
296impl ExampleCtx {
297  pub const TEMPLATE: &'static str = "example";
298
299  pub fn new(render_ctx: &RenderContext, example: &str, i: usize) -> Self {
300    let id = IdBuilder::new(render_ctx.ctx)
301      .kind(IdKind::Example)
302      .index(i)
303      .build();
304
305    let (maybe_title, body) = split_markdown_title(example);
306    let title = if let Some(title) = maybe_title {
307      title.to_string()
308    } else {
309      format!("Example {}", i + 1)
310    };
311
312    let markdown_title = render_markdown(render_ctx, &title, false);
313    let markdown_body =
314      render_markdown(render_ctx, body.unwrap_or_default(), true);
315
316    ExampleCtx {
317      anchor: AnchorCtx { id: id.clone() },
318      id,
319      title,
320      markdown_title,
321      markdown_body,
322    }
323  }
324}
325
326#[derive(Debug, Serialize, Clone, Default)]
327pub struct ModuleDocCtx {
328  pub deprecated: Option<String>,
329  pub sections: super::SymbolContentCtx,
330}
331
332impl ModuleDocCtx {
333  pub const TEMPLATE: &'static str = "module_doc";
334
335  pub fn new(render_ctx: &RenderContext, short_path: &ShortPath) -> Self {
336    let module_doc_nodes = render_ctx.ctx.doc_nodes.get(short_path).unwrap();
337
338    let mut sections = Vec::with_capacity(7);
339
340    let (deprecated, html) = if let Some(node) = module_doc_nodes
341      .iter()
342      .find(|n| matches!(n.def, DocNodeDef::ModuleDoc))
343    {
344      let deprecated = node.js_doc.tags.iter().find_map(|tag| {
345        if let JsDocTag::Deprecated { doc } = tag {
346          Some(render_markdown(
347            render_ctx,
348            doc.as_deref().unwrap_or_default(),
349            false,
350          ))
351        } else {
352          None
353        }
354      });
355
356      if let Some(examples) = jsdoc_examples(render_ctx, &node.js_doc) {
357        sections.push(examples);
358      }
359
360      let html = jsdoc_body_to_html(render_ctx, &node.js_doc, false);
361
362      (deprecated, html)
363    } else {
364      (None, None)
365    };
366
367    if !short_path.is_main {
368      let partitions_by_kind = super::partition::partition_nodes_by_kind(
369        render_ctx.ctx,
370        module_doc_nodes.iter().map(Cow::Borrowed),
371        true,
372      );
373
374      sections.extend(super::namespace::render_namespace(
375        partitions_by_kind.into_iter().map(|(title, nodes)| {
376          (
377            render_ctx.clone(),
378            Some(SectionHeaderCtx {
379              title: title.clone(),
380              anchor: AnchorCtx {
381                id: super::util::Id::new(title),
382              },
383              href: None,
384              doc: None,
385            }),
386            nodes,
387          )
388        }),
389      ));
390    }
391
392    Self {
393      deprecated,
394      sections: super::SymbolContentCtx {
395        id: Id::new("module_doc"),
396        docs: html,
397        sections,
398      },
399    }
400  }
401}
402
403#[cfg(test)]
404mod test {
405  use crate::html::href_path_resolve;
406  use crate::html::jsdoc::parse_links;
407  use crate::html::GenerateCtx;
408  use crate::html::GenerateOptions;
409  use crate::html::HrefResolver;
410  use crate::html::UsageComposer;
411  use crate::html::UsageComposerEntry;
412  use crate::DocNode;
413  use crate::Location;
414  use deno_ast::ModuleSpecifier;
415  use indexmap::IndexMap;
416  use std::rc::Rc;
417
418  use crate::html::usage::UsageToMd;
419  use crate::html::RenderContext;
420  use crate::html::UrlResolveKind;
421  use crate::interface::InterfaceDef;
422  use crate::js_doc::JsDoc;
423  use crate::node::DeclarationKind;
424
425  struct EmptyResolver;
426
427  impl HrefResolver for EmptyResolver {
428    fn resolve_path(
429      &self,
430      current: UrlResolveKind,
431      target: UrlResolveKind,
432    ) -> String {
433      href_path_resolve(current, target)
434    }
435
436    fn resolve_global_symbol(&self, _symbol: &[String]) -> Option<String> {
437      None
438    }
439
440    fn resolve_import_href(
441      &self,
442      _symbol: &[String],
443      _src: &str,
444    ) -> Option<String> {
445      None
446    }
447
448    fn resolve_source(&self, _location: &Location) -> Option<String> {
449      None
450    }
451
452    fn resolve_external_jsdoc_module(
453      &self,
454      _module: &str,
455      _symbol: Option<&str>,
456    ) -> Option<(String, String)> {
457      None
458    }
459  }
460
461  impl UsageComposer for EmptyResolver {
462    fn is_single_mode(&self) -> bool {
463      true
464    }
465
466    fn compose(
467      &self,
468      current_resolve: UrlResolveKind,
469      usage_to_md: UsageToMd,
470    ) -> IndexMap<UsageComposerEntry, String> {
471      current_resolve
472        .get_file()
473        .map(|current_file| {
474          IndexMap::from([(
475            UsageComposerEntry {
476              name: "".to_string(),
477              icon: None,
478            },
479            usage_to_md(current_file.display_name(), None),
480          )])
481        })
482        .unwrap_or_default()
483    }
484  }
485
486  #[test]
487  fn parse_links_test() {
488    let ctx = GenerateCtx::new(
489      GenerateOptions {
490        package_name: None,
491        main_entrypoint: None,
492        href_resolver: Rc::new(EmptyResolver),
493        usage_composer: Rc::new(EmptyResolver),
494        rewrite_map: None,
495        category_docs: None,
496        disable_search: false,
497        symbol_redirect_map: None,
498        default_symbol_map: None,
499        markdown_renderer: crate::html::comrak::create_renderer(
500          None, None, None,
501        ),
502        markdown_stripper: Rc::new(crate::html::comrak::strip),
503        head_inject: None,
504        id_prefix: None,
505      },
506      Default::default(),
507      Default::default(),
508      IndexMap::from([
509        (
510          ModuleSpecifier::parse("file:///a.ts").unwrap(),
511          vec![
512            DocNode::interface(
513              "foo".into(),
514              false,
515              Location::default(),
516              DeclarationKind::Export,
517              JsDoc::default(),
518              InterfaceDef {
519                def_name: None,
520                extends: vec![],
521                constructors: vec![],
522                methods: vec![],
523                properties: vec![],
524                call_signatures: vec![],
525                index_signatures: vec![],
526                type_params: Box::new([]),
527              },
528            ),
529            DocNode::interface(
530              "bar".into(),
531              false,
532              Location::default(),
533              DeclarationKind::Export,
534              JsDoc::default(),
535              InterfaceDef {
536                def_name: None,
537                extends: vec![],
538                constructors: vec![],
539                methods: vec![],
540                properties: vec![],
541                call_signatures: vec![],
542                index_signatures: vec![],
543                type_params: Box::new([]),
544              },
545            ),
546          ],
547        ),
548        (
549          ModuleSpecifier::parse("file:///b.ts").unwrap(),
550          vec![DocNode::interface(
551            "baz".into(),
552            false,
553            Location::default(),
554            DeclarationKind::Export,
555            JsDoc::default(),
556            InterfaceDef {
557              def_name: None,
558              extends: vec![],
559              constructors: vec![],
560              methods: vec![],
561              properties: vec![],
562              call_signatures: vec![],
563              index_signatures: vec![],
564              type_params: Box::new([]),
565            },
566          )],
567        ),
568      ]),
569    )
570    .unwrap();
571
572    let (a_short_path, nodes) = ctx.doc_nodes.first().unwrap();
573
574    let render_ctx = RenderContext::new(
575      &ctx,
576      nodes,
577      UrlResolveKind::Symbol {
578        file: a_short_path,
579        symbol: "foo",
580      },
581    );
582
583    assert_eq!(
584      parse_links("foo {@link https://example.com} bar", &render_ctx, false),
585      "foo [https://example.com](https://example.com) bar"
586    );
587    assert_eq!(
588      parse_links(
589        "foo {@linkcode https://example.com} bar",
590        &render_ctx,
591        false
592      ),
593      "foo [`https://example.com`](https://example.com) bar"
594    );
595
596    assert_eq!(
597      parse_links(
598        "foo {@link https://example.com Example} bar",
599        &render_ctx,
600        false
601      ),
602      "foo [Example](https://example.com) bar"
603    );
604    assert_eq!(
605      parse_links(
606        "foo {@link https://example.com|Example} bar",
607        &render_ctx,
608        false
609      ),
610      "foo [Example](https://example.com) bar"
611    );
612    assert_eq!(
613      parse_links(
614        "foo {@linkcode https://example.com Example} bar",
615        &render_ctx,
616        false,
617      ),
618      "foo [`Example`](https://example.com) bar"
619    );
620
621    assert_eq!(
622      parse_links("foo {@link unknownSymbol} bar", &render_ctx, false),
623      "foo unknownSymbol bar"
624    );
625    assert_eq!(
626      parse_links("foo {@linkcode unknownSymbol} bar", &render_ctx, false),
627      "foo `unknownSymbol` bar"
628    );
629
630    #[cfg(not(target_os = "windows"))]
631    {
632      assert_eq!(
633        parse_links("foo {@link bar} bar", &render_ctx, false),
634        "foo [bar](../../.././/a.ts/~/bar.html) bar"
635      );
636      assert_eq!(
637        parse_links("foo {@linkcode bar} bar", &render_ctx, false),
638        "foo [`bar`](../../.././/a.ts/~/bar.html) bar"
639      );
640
641      assert_eq!(
642        parse_links("foo {@link [b.ts]} bar", &render_ctx, false),
643        "foo [b.ts](../../.././/b.ts/index.html) bar"
644      );
645      assert_eq!(
646        parse_links("foo {@linkcode [b.ts]} bar", &render_ctx, false),
647        "foo [`b.ts`](../../.././/b.ts/index.html) bar"
648      );
649
650      assert_eq!(
651        parse_links("foo {@link [b.ts].baz} bar", &render_ctx, false),
652        "foo [b.ts baz](../../.././/b.ts/~/baz.html) bar"
653      );
654      assert_eq!(
655        parse_links("foo {@linkcode [b.ts].baz} bar", &render_ctx, false),
656        "foo [`b.ts baz`](../../.././/b.ts/~/baz.html) bar"
657      );
658    }
659  }
660}