Skip to main content

tuika_html/
lib.rs

1//! Terminal-native HTML for [tuika](https://crates.io/crates/tuika).
2//!
3//! Two ways in, one engine behind both:
4//!
5//! - [`HtmlRenderer`] plugs into markdown through tuika's
6//!   [`MarkdownBlockRenderer`] seam. One implementation handles raw `<details>`,
7//!   `<table>`, and `<div>` blocks as well as ` ```html ` fences.
8//! - [`Html`] is a `View`, the standalone viewer: hand it a fragment, place it
9//!   in a layout, and it paints like [`Markdown`](tuika::components::Markdown)
10//!   does.
11//!
12//! ```
13//! use tuika::prelude::*;
14//! use tuika_html::{Html, HtmlRenderer};
15//!
16//! // In markdown:
17//! let renderer = HtmlRenderer::new();
18//! let doc = Markdown::new("<details><summary>Notes</summary>Body</details>")
19//!     .block_renderer(&renderer);
20//!
21//! // Standalone:
22//! let view = Html::new("<h1>Title</h1><p>Prose with <b>bold</b>.</p>");
23//! # let _ = (doc, view);
24//! ```
25//!
26//! # What renders
27//!
28//! Headings, paragraphs, lists (ordered, unordered, nested), definition lists,
29//! block quotes, `<pre>`, `<hr>`, `<table>`, `<details>`/`<summary>`, and the
30//! presentational inline elements — `<b>`, `<em>`, `<code>`, `<kbd>`, `<mark>`,
31//! `<a>`, `<img>` (as alt text), `<br>`, `<sub>`, `<sup>`. Unknown elements stay
32//! transparent, so their text still shows. `<script>`, `<style>`, and embedded
33//! objects are dropped with their content.
34//!
35//! Styling resolves through the active [`StyleSheet`] roles rather than colors
36//! of its own, so HTML inherits the host's theme along with everything else on
37//! the screen.
38//!
39//! # What does not
40//!
41//! There is no CSS, no `style` attribute, no floats or positioning, and no
42//! layout that depends on the document outside the fragment. This renders
43//! *content*, not pages — the goal is that HTML in a transcript reads as well as
44//! the markdown around it, not that a terminal becomes a browser.
45//!
46//! # Untrusted input
47//!
48//! HTML in a transcript is untrusted. Control bytes are stripped before any text
49//! becomes a cell, so markup can never emit terminal commands; input, output,
50//! and nesting are bounded by [`Limits`], and exceeding either the input-size or
51//! the nesting bound returns `None` (markdown then drops the block) rather than
52//! doing unbounded work. Nesting is measured on the source *before* parsing,
53//! because html5ever builds — and drops — its tree recursively, and deep enough
54//! markup overflows the stack inside the size bound. No network is touched:
55//! `<img>` becomes its alt text, never a fetch.
56//!
57//! [`MarkdownBlockRenderer`]: tuika::components::MarkdownBlockRenderer
58//! [`StyleSheet`]: tuika::style::StyleSheet
59
60use ratatui_core::text::Line;
61use tuika::Theme;
62use tuika::components::{MarkdownBlock, MarkdownBlockContext, MarkdownBlockRenderer};
63use tuika::style::StyleSheet;
64
65mod block;
66mod dom;
67mod inline;
68mod table;
69mod view;
70
71pub use view::Html;
72
73/// Bounds on one render.
74///
75/// Untrusted markup must degrade — dropped, or truncated — rather than consume
76/// the frame, so every render is capped in three directions at once.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub struct Limits {
79    /// Fragments larger than this are refused outright (`render` returns
80    /// `None`). Default: 256 KiB.
81    pub max_input_bytes: usize,
82    /// Output lines kept; the rest are dropped. Default: 4096.
83    pub max_lines: usize,
84    /// Element nesting accepted. A fragment nested deeper than this is refused
85    /// outright (`render` returns `None`), because the *parser* recurses: a tree
86    /// built from arbitrarily deep markup overflows the stack before any of this
87    /// crate's code runs. Deeper subtrees inside an accepted fragment are also
88    /// dropped rather than walked. Default: 64.
89    pub max_depth: usize,
90}
91
92impl Default for Limits {
93    fn default() -> Self {
94        Self {
95            max_input_bytes: 256 * 1024,
96            max_lines: 4096,
97            max_depth: 64,
98        }
99    }
100}
101
102/// Renders HTML for tuika's structured markdown block seam.
103///
104/// One value serves both raw block HTML and ` ```html ` fences. Attach it with
105/// [`Markdown::block_renderer`](tuika::components::Markdown::block_renderer).
106///
107/// ```
108/// use tuika::prelude::*;
109/// use tuika_html::HtmlRenderer;
110///
111/// let renderer = HtmlRenderer::new();
112/// let doc = Markdown::new("<ul><li>one</li><li>two</li></ul>")
113///     .block_renderer(&renderer);
114/// # let _ = doc;
115/// ```
116///
117/// ![HTML blocks in markdown](https://raw.githubusercontent.com/everruns/tuika/main/crates/tuika-html/examples/html_markdown/html.png)
118///
119/// `cargo run -p tuika-html --example html_markdown` is the scene above.
120#[derive(Clone, Copy, Debug, Default)]
121pub struct HtmlRenderer {
122    limits: Limits,
123}
124
125impl HtmlRenderer {
126    /// A renderer with the default [`Limits`].
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// A renderer with custom [`Limits`].
132    pub fn with_limits(limits: Limits) -> Self {
133        Self { limits }
134    }
135
136    /// The bounds this renderer applies.
137    pub fn limits(&self) -> Limits {
138        self.limits
139    }
140}
141
142impl MarkdownBlockRenderer for HtmlRenderer {
143    fn render(
144        &self,
145        block: MarkdownBlock<'_>,
146        context: MarkdownBlockContext<'_>,
147    ) -> Option<Vec<Line<'static>>> {
148        let source = match block {
149            MarkdownBlock::Html { source } => source,
150            MarkdownBlock::Fenced { language, source }
151                if matches!(
152                    language.to_ascii_lowercase().as_str(),
153                    "html" | "htm" | "xhtml"
154                ) =>
155            {
156                source
157            }
158            _ => return None,
159        };
160        let lines = to_lines_with_limits(
161            source,
162            context.width,
163            context.theme,
164            context.sheet,
165            self.limits,
166        )?;
167        // An empty result would silently swallow a fence's visible fallback.
168        (!lines.is_empty()).then_some(lines)
169    }
170}
171
172/// Render an HTML fragment to width-fitted styled lines.
173///
174/// Draw the result **without** further wrapping — it is already fitted to
175/// `width`, and `<pre>` content must not be re-flowed.
176///
177/// ```
178/// use tuika::prelude::*;
179/// let theme = Theme::default();
180/// let sheet = StyleSheet::from_theme(&theme);
181/// let lines = tuika_html::to_lines("<p>Hello <b>world</b></p>", 40, &theme, &sheet);
182/// assert_eq!(lines.len(), 1);
183///
184/// // Block elements are separated, and each is fitted to the width.
185/// let page = tuika_html::to_lines("<h1>Title</h1><p>Prose.</p>", 40, &theme, &sheet);
186/// assert_eq!(page.len(), 3); // heading, blank, prose
187/// ```
188pub fn to_lines(html: &str, width: u16, theme: &Theme, sheet: &StyleSheet) -> Vec<Line<'static>> {
189    to_lines_with_limits(html, width, theme, sheet, Limits::default()).unwrap_or_default()
190}
191
192/// [`to_lines`] with explicit [`Limits`]: `None` when the input is over
193/// `max_input_bytes`.
194pub fn to_lines_with_limits(
195    html: &str,
196    width: u16,
197    theme: &Theme,
198    sheet: &StyleSheet,
199    limits: Limits,
200) -> Option<Vec<Line<'static>>> {
201    if html.len() > limits.max_input_bytes {
202        return None;
203    }
204    // Measured on the source, before parsing: see `dom::max_depth`.
205    if dom::max_depth(html) > limits.max_depth {
206        return None;
207    }
208    let root = dom::parse(html);
209    Some(block::Layout::new(theme, sheet, limits).render(&root, width))
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use ratatui_core::style::Modifier;
216
217    /// The plain text of a render, one string per line.
218    fn plain(html: &str, width: u16) -> Vec<String> {
219        let theme = Theme::default();
220        to_lines(html, width, &theme, &StyleSheet::from_theme(&theme))
221            .iter()
222            .map(|l| {
223                l.spans
224                    .iter()
225                    .map(|s| s.content.as_ref())
226                    .collect::<String>()
227                    .trim_end()
228                    .to_string()
229            })
230            .collect()
231    }
232
233    fn styled(html: &str, needle: &str) -> ratatui_core::style::Style {
234        let theme = Theme::default();
235        to_lines(html, 60, &theme, &StyleSheet::from_theme(&theme))
236            .iter()
237            .flat_map(|l| l.spans.clone())
238            .find(|s| s.content.contains(needle))
239            .unwrap_or_else(|| panic!("no span containing {needle:?}"))
240            .style
241    }
242
243    #[test]
244    fn headings_and_paragraphs_are_separated() {
245        let out = plain("<h1>Title</h1><p>Some prose.</p>", 40);
246        assert_eq!(out, vec!["Title", "", "Some prose."]);
247        assert!(
248            styled("<h1>Title</h1>", "Title")
249                .add_modifier
250                .contains(Modifier::BOLD)
251        );
252    }
253
254    #[test]
255    fn prose_wraps_to_the_width() {
256        let out = plain("<p>one two three four five six seven</p>", 12);
257        assert!(out.len() > 1, "{out:?}");
258        assert!(out.iter().all(|l| l.chars().count() <= 12), "{out:?}");
259    }
260
261    #[test]
262    fn lists_number_nest_and_hang() {
263        let out = plain(
264            "<ol start=3><li>first</li><li>second<ul><li>inner</li></ul></li></ol>",
265            40,
266        );
267        assert!(out.iter().any(|l| l == "3. first"), "{out:?}");
268        assert!(out.iter().any(|l| l == "4. second"), "{out:?}");
269        assert!(out.iter().any(|l| l.trim() == "• inner"), "{out:?}");
270        let inner = out.iter().find(|l| l.contains("inner")).unwrap();
271        assert!(inner.starts_with("   "), "nested under its item: {inner:?}");
272    }
273
274    #[test]
275    fn a_long_list_item_hangs_under_its_marker() {
276        let out = plain("<ul><li>one two three four five six</li></ul>", 14);
277        assert!(out[0].starts_with("• "), "{out:?}");
278        for line in &out[1..] {
279            assert!(line.starts_with("  "), "continuation hangs: {out:?}");
280        }
281    }
282
283    #[test]
284    fn block_quotes_indent_their_content() {
285        let out = plain("<blockquote><p>quoted</p></blockquote>", 40);
286        assert!(out.iter().any(|l| l == "  quoted"), "{out:?}");
287    }
288
289    #[test]
290    fn pre_is_verbatim_and_never_wrapped() {
291        let out = plain("<pre>  keep   spacing\nand lines</pre>", 40);
292        assert!(out.iter().any(|l| l == "  keep   spacing"), "{out:?}");
293        assert!(out.iter().any(|l| l == "and lines"), "{out:?}");
294
295        let long = format!("<pre>{}</pre>", "x".repeat(60));
296        let wide = plain(&long, 20);
297        assert_eq!(wide.len(), 1, "code must not wrap: {wide:?}");
298    }
299
300    #[test]
301    fn details_shows_its_summary_and_indents_the_body() {
302        let out = plain("<details><summary>More</summary><p>Body</p></details>", 40);
303        assert!(out.iter().any(|l| l == "▸ More"), "{out:?}");
304        assert!(out.iter().any(|l| l == "  Body"), "{out:?}");
305        // An open one says so.
306        let open = plain("<details open><summary>More</summary>x</details>", 40);
307        assert!(open.iter().any(|l| l == "▾ More"), "{open:?}");
308    }
309
310    #[test]
311    fn tables_are_boxed_and_fitted() {
312        let out = plain(
313            "<table><tr><th>Name</th><th>Role</th></tr>\
314             <tr><td>Ada</td><td>Author</td></tr></table>",
315            40,
316        );
317        assert!(out[0].starts_with('╭'), "{out:?}");
318        assert!(out.iter().any(|l| l.contains("Name") && l.contains("Role")));
319        assert!(
320            out.iter()
321                .any(|l| l.contains("Ada") && l.contains("Author"))
322        );
323        assert!(out.last().unwrap().starts_with('╰'), "{out:?}");
324        for line in &out {
325            assert!(line.chars().count() <= 40, "over width: {line:?}");
326        }
327    }
328
329    #[test]
330    fn a_table_too_narrow_for_a_grid_keeps_its_content() {
331        let out = plain(
332            "<table><tr><th>Name</th><th>Role</th></tr>\
333             <tr><td>Ada</td><td>Author</td></tr></table>",
334            12,
335        );
336        let joined = out.join(" ");
337        assert!(joined.contains("Ada"), "{out:?}");
338        assert!(joined.contains("Author"), "{out:?}");
339        for line in &out {
340            assert!(line.chars().count() <= 12, "over width: {line:?}");
341        }
342    }
343
344    #[test]
345    fn a_headerless_table_still_renders() {
346        let out = plain("<table><tr><td>a</td><td>b</td></tr></table>", 40);
347        assert!(
348            out.iter().any(|l| l.contains('a') && l.contains('b')),
349            "{out:?}"
350        );
351    }
352
353    #[test]
354    fn horizontal_rules_are_themed() {
355        let out = plain("<p>a</p><hr><p>b</p>", 40);
356        assert!(out.iter().any(|l| l.starts_with("───")), "{out:?}");
357    }
358
359    #[test]
360    fn definition_lists_render_terms_and_definitions() {
361        let out = plain("<dl><dt>Term</dt><dd>Meaning</dd></dl>", 40);
362        assert!(out.iter().any(|l| l == "Term"), "{out:?}");
363        assert!(out.iter().any(|l| l == "  Meaning"), "{out:?}");
364    }
365
366    #[test]
367    fn malformed_markup_degrades_instead_of_failing() {
368        for html in [
369            "<p>unclosed",
370            "</p>stray close",
371            "<ul><li>a<li>b",
372            "<table><td>lonely cell",
373            "<b><i>crossed</b></i>",
374            "<div".repeat(50).as_str(),
375            "&notanentity; &amp; &#x41;",
376        ] {
377            let out = plain(html, 30);
378            for line in &out {
379                assert!(line.chars().count() <= 30, "{html:?} -> {line:?}");
380            }
381        }
382        assert!(plain("<p>unclosed", 30).iter().any(|l| l == "unclosed"));
383        assert!(plain("&amp; &#x41;", 30).iter().any(|l| l == "& A"));
384    }
385
386    #[test]
387    fn deep_nesting_is_bounded() {
388        let deep = format!("{}deep{}", "<div>".repeat(500), "</div>".repeat(500));
389        // Past `max_depth` the subtree is dropped rather than recursed into —
390        // what matters is that it returns at all.
391        let out = plain(&deep, 30);
392        assert!(out.len() <= 2, "{out:?}");
393    }
394
395    #[test]
396    fn oversized_input_is_refused_rather_than_rendered() {
397        let theme = Theme::default();
398        let sheet = StyleSheet::from_theme(&theme);
399        let limits = Limits {
400            max_input_bytes: 16,
401            ..Limits::default()
402        };
403        assert!(
404            to_lines_with_limits("<p>much too long for this</p>", 40, &theme, &sheet, limits)
405                .is_none()
406        );
407        assert!(to_lines_with_limits("<p>ok</p>", 40, &theme, &sheet, limits).is_some());
408    }
409
410    #[test]
411    fn output_is_capped() {
412        let theme = Theme::default();
413        let sheet = StyleSheet::from_theme(&theme);
414        let limits = Limits {
415            max_lines: 5,
416            ..Limits::default()
417        };
418        let many = "<p>x</p>".repeat(100);
419        let lines = to_lines_with_limits(&many, 40, &theme, &sheet, limits).expect("rendered");
420        assert!(lines.len() <= 5, "{}", lines.len());
421    }
422
423    #[test]
424    fn styling_follows_the_stylesheet() {
425        use ratatui_core::style::Color;
426        let theme = Theme::default();
427        let sheet = StyleSheet {
428            strong: tuika::style::StyleBundle::new().fg(Color::Green),
429            ..StyleSheet::from_theme(&theme)
430        };
431        let lines = to_lines("<p><b>bold</b></p>", 40, &theme, &sheet);
432        let span = lines[0]
433            .spans
434            .iter()
435            .find(|s| s.content.contains("bold"))
436            .expect("bold span");
437        assert_eq!(span.style.fg, Some(Color::Green));
438    }
439
440    #[test]
441    fn one_renderer_serves_both_markdown_block_kinds() {
442        let theme = Theme::default();
443        let sheet = StyleSheet::from_theme(&theme);
444        let renderer = HtmlRenderer::new();
445        let context = MarkdownBlockContext::new(20, &theme, &sheet);
446
447        let block = renderer.render(
448            MarkdownBlock::Html {
449                source: "<p>hi</p>",
450            },
451            context,
452        );
453        assert!(block.is_some());
454        let fence = renderer.render(
455            MarkdownBlock::Fenced {
456                language: "html",
457                source: "<p>hi</p>",
458            },
459            context,
460        );
461        assert!(fence.is_some());
462        // Other languages stay with markdown's code-block presentation.
463        assert!(
464            renderer
465                .render(
466                    MarkdownBlock::Fenced {
467                        language: "rust",
468                        source: "fn main() {}",
469                    },
470                    context,
471                )
472                .is_none()
473        );
474        // Nothing to show is `None`, so markdown's own drop path handles it.
475        assert!(
476            renderer
477                .render(
478                    MarkdownBlock::Html {
479                        source: "<!-- note -->",
480                    },
481                    context,
482                )
483                .is_none()
484        );
485    }
486}