use ratatui_core::text::Line;
use tuika::Theme;
use tuika::components::{MarkdownBlock, MarkdownBlockContext, MarkdownBlockRenderer};
use tuika::style::StyleSheet;
mod block;
mod dom;
mod inline;
mod table;
mod view;
pub use view::Html;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
pub max_input_bytes: usize,
pub max_lines: usize,
pub max_depth: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_input_bytes: 256 * 1024,
max_lines: 4096,
max_depth: 64,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct HtmlRenderer {
limits: Limits,
}
impl HtmlRenderer {
pub fn new() -> Self {
Self::default()
}
pub fn with_limits(limits: Limits) -> Self {
Self { limits }
}
pub fn limits(&self) -> Limits {
self.limits
}
}
impl MarkdownBlockRenderer for HtmlRenderer {
fn render(
&self,
block: MarkdownBlock<'_>,
context: MarkdownBlockContext<'_>,
) -> Option<Vec<Line<'static>>> {
let source = match block {
MarkdownBlock::Html { source } => source,
MarkdownBlock::Fenced { language, source }
if matches!(
language.to_ascii_lowercase().as_str(),
"html" | "htm" | "xhtml"
) =>
{
source
}
_ => return None,
};
let lines = to_lines_with_limits(
source,
context.width,
context.theme,
context.sheet,
self.limits,
)?;
(!lines.is_empty()).then_some(lines)
}
}
pub fn to_lines(html: &str, width: u16, theme: &Theme, sheet: &StyleSheet) -> Vec<Line<'static>> {
to_lines_with_limits(html, width, theme, sheet, Limits::default()).unwrap_or_default()
}
pub fn to_lines_with_limits(
html: &str,
width: u16,
theme: &Theme,
sheet: &StyleSheet,
limits: Limits,
) -> Option<Vec<Line<'static>>> {
if html.len() > limits.max_input_bytes {
return None;
}
if dom::max_depth(html) > limits.max_depth {
return None;
}
let root = dom::parse(html);
Some(block::Layout::new(theme, sheet, limits).render(&root, width))
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui_core::style::Modifier;
fn plain(html: &str, width: u16) -> Vec<String> {
let theme = Theme::default();
to_lines(html, width, &theme, &StyleSheet::from_theme(&theme))
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn styled(html: &str, needle: &str) -> ratatui_core::style::Style {
let theme = Theme::default();
to_lines(html, 60, &theme, &StyleSheet::from_theme(&theme))
.iter()
.flat_map(|l| l.spans.clone())
.find(|s| s.content.contains(needle))
.unwrap_or_else(|| panic!("no span containing {needle:?}"))
.style
}
#[test]
fn headings_and_paragraphs_are_separated() {
let out = plain("<h1>Title</h1><p>Some prose.</p>", 40);
assert_eq!(out, vec!["Title", "", "Some prose."]);
assert!(
styled("<h1>Title</h1>", "Title")
.add_modifier
.contains(Modifier::BOLD)
);
}
#[test]
fn prose_wraps_to_the_width() {
let out = plain("<p>one two three four five six seven</p>", 12);
assert!(out.len() > 1, "{out:?}");
assert!(out.iter().all(|l| l.chars().count() <= 12), "{out:?}");
}
#[test]
fn lists_number_nest_and_hang() {
let out = plain(
"<ol start=3><li>first</li><li>second<ul><li>inner</li></ul></li></ol>",
40,
);
assert!(out.iter().any(|l| l == "3. first"), "{out:?}");
assert!(out.iter().any(|l| l == "4. second"), "{out:?}");
assert!(out.iter().any(|l| l.trim() == "• inner"), "{out:?}");
let inner = out.iter().find(|l| l.contains("inner")).unwrap();
assert!(inner.starts_with(" "), "nested under its item: {inner:?}");
}
#[test]
fn a_long_list_item_hangs_under_its_marker() {
let out = plain("<ul><li>one two three four five six</li></ul>", 14);
assert!(out[0].starts_with("• "), "{out:?}");
for line in &out[1..] {
assert!(line.starts_with(" "), "continuation hangs: {out:?}");
}
}
#[test]
fn block_quotes_indent_their_content() {
let out = plain("<blockquote><p>quoted</p></blockquote>", 40);
assert!(out.iter().any(|l| l == " quoted"), "{out:?}");
}
#[test]
fn pre_is_verbatim_and_never_wrapped() {
let out = plain("<pre> keep spacing\nand lines</pre>", 40);
assert!(out.iter().any(|l| l == " keep spacing"), "{out:?}");
assert!(out.iter().any(|l| l == "and lines"), "{out:?}");
let long = format!("<pre>{}</pre>", "x".repeat(60));
let wide = plain(&long, 20);
assert_eq!(wide.len(), 1, "code must not wrap: {wide:?}");
}
#[test]
fn details_shows_its_summary_and_indents_the_body() {
let out = plain("<details><summary>More</summary><p>Body</p></details>", 40);
assert!(out.iter().any(|l| l == "▸ More"), "{out:?}");
assert!(out.iter().any(|l| l == " Body"), "{out:?}");
let open = plain("<details open><summary>More</summary>x</details>", 40);
assert!(open.iter().any(|l| l == "▾ More"), "{open:?}");
}
#[test]
fn tables_are_boxed_and_fitted() {
let out = plain(
"<table><tr><th>Name</th><th>Role</th></tr>\
<tr><td>Ada</td><td>Author</td></tr></table>",
40,
);
assert!(out[0].starts_with('╭'), "{out:?}");
assert!(out.iter().any(|l| l.contains("Name") && l.contains("Role")));
assert!(
out.iter()
.any(|l| l.contains("Ada") && l.contains("Author"))
);
assert!(out.last().unwrap().starts_with('╰'), "{out:?}");
for line in &out {
assert!(line.chars().count() <= 40, "over width: {line:?}");
}
}
#[test]
fn a_table_too_narrow_for_a_grid_keeps_its_content() {
let out = plain(
"<table><tr><th>Name</th><th>Role</th></tr>\
<tr><td>Ada</td><td>Author</td></tr></table>",
12,
);
let joined = out.join(" ");
assert!(joined.contains("Ada"), "{out:?}");
assert!(joined.contains("Author"), "{out:?}");
for line in &out {
assert!(line.chars().count() <= 12, "over width: {line:?}");
}
}
#[test]
fn a_headerless_table_still_renders() {
let out = plain("<table><tr><td>a</td><td>b</td></tr></table>", 40);
assert!(
out.iter().any(|l| l.contains('a') && l.contains('b')),
"{out:?}"
);
}
#[test]
fn horizontal_rules_are_themed() {
let out = plain("<p>a</p><hr><p>b</p>", 40);
assert!(out.iter().any(|l| l.starts_with("───")), "{out:?}");
}
#[test]
fn definition_lists_render_terms_and_definitions() {
let out = plain("<dl><dt>Term</dt><dd>Meaning</dd></dl>", 40);
assert!(out.iter().any(|l| l == "Term"), "{out:?}");
assert!(out.iter().any(|l| l == " Meaning"), "{out:?}");
}
#[test]
fn malformed_markup_degrades_instead_of_failing() {
for html in [
"<p>unclosed",
"</p>stray close",
"<ul><li>a<li>b",
"<table><td>lonely cell",
"<b><i>crossed</b></i>",
"<div".repeat(50).as_str(),
"¬anentity; & A",
] {
let out = plain(html, 30);
for line in &out {
assert!(line.chars().count() <= 30, "{html:?} -> {line:?}");
}
}
assert!(plain("<p>unclosed", 30).iter().any(|l| l == "unclosed"));
assert!(plain("& A", 30).iter().any(|l| l == "& A"));
}
#[test]
fn deep_nesting_is_bounded() {
let deep = format!("{}deep{}", "<div>".repeat(500), "</div>".repeat(500));
let out = plain(&deep, 30);
assert!(out.len() <= 2, "{out:?}");
}
#[test]
fn oversized_input_is_refused_rather_than_rendered() {
let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme);
let limits = Limits {
max_input_bytes: 16,
..Limits::default()
};
assert!(
to_lines_with_limits("<p>much too long for this</p>", 40, &theme, &sheet, limits)
.is_none()
);
assert!(to_lines_with_limits("<p>ok</p>", 40, &theme, &sheet, limits).is_some());
}
#[test]
fn output_is_capped() {
let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme);
let limits = Limits {
max_lines: 5,
..Limits::default()
};
let many = "<p>x</p>".repeat(100);
let lines = to_lines_with_limits(&many, 40, &theme, &sheet, limits).expect("rendered");
assert!(lines.len() <= 5, "{}", lines.len());
}
#[test]
fn styling_follows_the_stylesheet() {
use ratatui_core::style::Color;
let theme = Theme::default();
let sheet = StyleSheet {
strong: tuika::style::StyleBundle::new().fg(Color::Green),
..StyleSheet::from_theme(&theme)
};
let lines = to_lines("<p><b>bold</b></p>", 40, &theme, &sheet);
let span = lines[0]
.spans
.iter()
.find(|s| s.content.contains("bold"))
.expect("bold span");
assert_eq!(span.style.fg, Some(Color::Green));
}
#[test]
fn one_renderer_serves_both_markdown_block_kinds() {
let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme);
let renderer = HtmlRenderer::new();
let context = MarkdownBlockContext::new(20, &theme, &sheet);
let block = renderer.render(
MarkdownBlock::Html {
source: "<p>hi</p>",
},
context,
);
assert!(block.is_some());
let fence = renderer.render(
MarkdownBlock::Fenced {
language: "html",
source: "<p>hi</p>",
},
context,
);
assert!(fence.is_some());
assert!(
renderer
.render(
MarkdownBlock::Fenced {
language: "rust",
source: "fn main() {}",
},
context,
)
.is_none()
);
assert!(
renderer
.render(
MarkdownBlock::Html {
source: "<!-- note -->",
},
context,
)
.is_none()
);
}
}