use crate::html::ShortPath;
use comrak::Arena;
use comrak::adapters::SyntaxHighlighterAdapter;
use comrak::nodes::AstNode;
use comrak::nodes::NodeValue;
use std::collections::HashMap;
use std::io::BufWriter;
use std::io::Write;
use std::sync::Arc;
pub const COMRAK_STYLESHEET: &str = include_str!("./templates/comrak.gen.css");
pub const COMRAK_STYLESHEET_FILENAME: &str = "comrak.css";
pub type NodeHook = Box<
dyn for<'a> Fn(
&'a Arena<AstNode<'a>>,
&'a AstNode<'a>,
&comrak::Options,
&comrak::Plugins,
) + Send
+ Sync,
>;
fn walk_node<'a>(
node_hook: &NodeHook,
arena: &'a Arena<AstNode<'a>>,
node: &'a AstNode<'a>,
options: &comrak::Options,
plugins: &comrak::Plugins,
) {
for child in node.children() {
node_hook(arena, child, options, plugins);
walk_node(node_hook, arena, child, options, plugins);
}
}
fn walk_node_title<'a>(node: &'a AstNode<'a>) {
for child in node.children() {
if matches!(
child.data.borrow().value,
NodeValue::Document
| NodeValue::Paragraph
| NodeValue::Heading(_)
| NodeValue::Text(_)
| NodeValue::Code(_)
| NodeValue::HtmlInline(_)
| NodeValue::Emph
| NodeValue::Strong
| NodeValue::Strikethrough
| NodeValue::Superscript
| NodeValue::Link(_)
| NodeValue::Math(_)
| NodeValue::Escaped
| NodeValue::WikiLink(_)
| NodeValue::Underline
| NodeValue::SoftBreak
) {
walk_node_title(child);
} else {
child.detach();
}
}
}
pub fn render_node<'a>(
node: &'a AstNode<'a>,
options: &comrak::Options,
plugins: &comrak::Plugins,
) -> String {
let mut bw = BufWriter::new(Vec::new());
comrak::format_html_with_plugins(node, options, &mut bw, plugins).unwrap();
String::from_utf8(bw.into_inner().unwrap()).unwrap()
}
pub fn default_options() -> comrak::Options<'static> {
let mut options = comrak::Options::default();
options.extension.autolink = true;
options.extension.description_lists = true;
options.extension.strikethrough = true;
options.extension.superscript = true;
options.extension.table = true;
options.extension.tagfilter = true;
options.extension.tasklist = true;
options
}
pub fn strip(md: &str) -> String {
let mut options = default_options();
options.render.escape = true;
let arena = Arena::new();
let root = comrak::parse_document(&arena, md, &options);
fn collect_text<'a>(node: &'a AstNode<'a>, output: &mut BufWriter<Vec<u8>>) {
match node.data.borrow().value {
NodeValue::Text(ref literal)
| NodeValue::Code(comrak::nodes::NodeCode { ref literal, .. }) => {
output.write_all(literal.as_bytes()).unwrap();
}
NodeValue::LineBreak | NodeValue::SoftBreak => {
output.write_all(b" ").unwrap()
}
_ => {
for n in node.children() {
collect_text(n, output);
}
}
}
}
let mut bw = BufWriter::new(Vec::new());
collect_text(root, &mut bw);
String::from_utf8(bw.into_inner().unwrap()).unwrap()
}
pub type HtmlClean = Box<dyn Fn(String) -> String + Send + Sync>;
pub fn create_renderer(
syntax_highlighter: Option<Arc<dyn SyntaxHighlighterAdapter>>,
node_hook: Option<NodeHook>,
clean: Option<HtmlClean>,
) -> super::jsdoc::MarkdownRenderer {
let has_clean = clean.is_some();
let renderer = move |md: &str,
title_only: bool,
_file_path: Option<ShortPath>,
anchorizer: super::jsdoc::Anchorizer|
-> Option<String> {
let mut options = default_options();
options.render.escape = !has_clean;
options.render.unsafe_ = has_clean;
let mut plugins = comrak::Plugins::default();
let heading_adapter = ComrakHeadingAdapter(anchorizer);
let highlight_adapter =
ComrakHighlightWrapperAdapter(syntax_highlighter.clone());
if !title_only {
plugins.render.codefence_syntax_highlighter = Some(&highlight_adapter);
plugins.render.heading_adapter = Some(&heading_adapter);
}
let html = {
let arena = Arena::new();
let root = comrak::parse_document(&arena, md, &options);
if title_only {
walk_node_title(root);
if let Some(child) = root.first_child() {
render_node(child, &options, &plugins)
} else {
return None;
}
} else {
if let Some(node_hook) = &node_hook {
walk_node(node_hook, &arena, root, &options, &plugins);
}
render_node(root, &options, &plugins)
}
};
let html = if let Some(clean) = &clean {
clean(html)
} else {
html
};
Some(html)
};
Arc::new(renderer)
}
pub struct ComrakHeadingAdapter(super::jsdoc::Anchorizer);
impl comrak::adapters::HeadingAdapter for ComrakHeadingAdapter {
fn enter(
&self,
output: &mut dyn Write,
heading: &comrak::adapters::HeadingMeta,
_sourcepos: Option<comrak::nodes::Sourcepos>,
) -> std::io::Result<()> {
let anchor = self.0(heading.content.clone(), heading.level);
writeln!(output, r#"<h{} id="{anchor}">"#, heading.level)
}
fn exit(
&self,
output: &mut dyn Write,
heading: &comrak::adapters::HeadingMeta,
) -> std::io::Result<()> {
writeln!(output, "</h{}>", heading.level)?;
Ok(())
}
}
pub struct ComrakHighlightWrapperAdapter(
pub Option<Arc<dyn SyntaxHighlighterAdapter>>,
);
impl SyntaxHighlighterAdapter for ComrakHighlightWrapperAdapter {
fn write_highlighted(
&self,
output: &mut dyn Write,
lang: Option<&str>,
code: &str,
) -> std::io::Result<()> {
let lang = lang.map(crate::util::example_code::language_token);
let processed = crate::util::example_code::process_example_code(lang, code);
let (displayed, copyable) = match &processed {
Some(example) => (example.displayed.as_str(), example.copyable.as_str()),
None => (code, code),
};
if let Some(adapter) = &self.0 {
adapter.write_highlighted(output, lang, displayed)?;
} else {
comrak::html::escape(output, displayed.as_bytes())?;
}
write!(output, "</code>")?;
write!(
output,
r#"<button class="copyButton" data-copy="{}">{}{}</button>"#,
html_escape::encode_double_quoted_attribute(copyable),
include_str!("./templates/icons/copy.svg"),
include_str!("./templates/icons/check.svg"),
)?;
write!(output, "<code>")
}
fn write_pre_tag(
&self,
output: &mut dyn Write,
mut attributes: HashMap<String, String>,
) -> std::io::Result<()> {
attributes.insert("class".to_string(), "highlight".to_string());
if let Some(adapter) = &self.0 {
adapter.write_pre_tag(output, attributes)
} else {
comrak::html::write_opening_tag(output, "pre", attributes)
}
}
fn write_code_tag(
&self,
output: &mut dyn Write,
attributes: HashMap<String, String>,
) -> std::io::Result<()> {
if let Some(adapter) = &self.0 {
adapter.write_code_tag(output, attributes)
} else {
comrak::html::write_opening_tag(output, "code", attributes)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(md: &str) -> String {
let renderer = create_renderer(None, None, None);
let anchorizer: super::super::jsdoc::Anchorizer =
Arc::new(|content: String, _level: u8| content);
renderer(md, false, None, anchorizer).unwrap()
}
#[test]
fn hides_example_code_lines() {
let html = render("```ts\n# const x = 1;\nconsole.log(x);\n```");
let displayed = html.split("</code>").next().unwrap();
assert!(!displayed.contains("const x = 1;"));
assert!(displayed.contains("console.log(x);"));
assert!(html.contains("data-copy="));
assert!(html.contains("const x = 1;"));
}
#[test]
fn does_not_hide_non_example_code_lines() {
let html = render("```sh\n# install\ndeno install\n```");
let displayed = html.split("</code>").next().unwrap();
assert!(displayed.contains("# install"));
}
#[test]
fn escapes_double_hash_in_example_code() {
let html = render("```ts\n## shown\n```");
let displayed = html.split("</code>").next().unwrap();
assert!(displayed.contains("# shown"));
assert!(!displayed.contains("## shown"));
}
#[test]
fn comma_in_info_string_is_treated_as_example() {
let html = render("```ts, no-eval\n# const x = 1;\nconsole.log(x);\n```");
let displayed = html.split("</code>").next().unwrap();
assert!(!displayed.contains("const x = 1;"));
assert!(displayed.contains("console.log(x);"));
assert!(html.contains("const x = 1;"));
}
}