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: ®ex::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}