Skip to main content

rtb_docs/
render.rs

1//! Markdown rendering adapters.
2//!
3//! # Two output paths
4//!
5//! - [`to_ratatui_text`] — builds a `ratatui::text::Text<'static>`
6//!   via `tui-markdown`. Paired with the TUI browser's right-hand
7//!   pane.
8//! - [`to_html_fragment`] / [`to_html_document`] — renders the same
9//!   markdown body as minimal HTML for the embedded server's
10//!   response. Built on `pulldown-cmark` directly.
11//!
12//! The in-house extras layer (tables, links rendered as underlined
13//! `Span`s, image-as-stub) lives here; `tui-markdown` handles the
14//! rest.
15
16use pulldown_cmark::{html::push_html, Options, Parser};
17use ratatui::text::Text;
18
19use crate::error::Result;
20
21/// Render `body` into a `ratatui::text::Text` suitable for display
22/// inside a `Paragraph` widget. Delegates the paragraph / emphasis /
23/// code-fence path to `tui-markdown`.
24///
25/// The returned `Text` borrows from the input; callers that need
26/// `'static` (e.g. to stash across frames) call
27/// [`text_into_owned`] to deep-clone every span.
28///
29/// # Errors
30///
31/// Currently infallible — `tui-markdown` surfaces parse issues as
32/// best-effort rendering, not hard failures. Wrapped in `Result` for
33/// forward-compat with the extras layer.
34pub fn to_ratatui_text(body: &str) -> Result<Text<'_>> {
35    Ok(tui_markdown::from_str(body))
36}
37
38/// Deep-clone `text` so every span owns its string data. Used by the
39/// browser to cache the rendered body across event-loop ticks.
40#[must_use]
41pub fn text_into_owned(text: Text<'_>) -> Text<'static> {
42    Text {
43        lines: text
44            .lines
45            .into_iter()
46            .map(|line| ratatui::text::Line {
47                spans: line
48                    .spans
49                    .into_iter()
50                    .map(|span| ratatui::text::Span {
51                        content: std::borrow::Cow::Owned(span.content.into_owned()),
52                        style: span.style,
53                    })
54                    .collect(),
55                style: line.style,
56                alignment: line.alignment,
57            })
58            .collect(),
59        style: text.style,
60        alignment: text.alignment,
61    }
62}
63
64/// Render `body` as a minimal HTML fragment (no surrounding
65/// `<html>` / `<head>`). Callers wrap in a full document.
66#[must_use]
67pub fn to_html_fragment(body: &str) -> String {
68    let parser = Parser::new_ext(body, extensions());
69    let mut html = String::with_capacity(body.len() * 2);
70    push_html(&mut html, parser);
71    html
72}
73
74/// Render `body` as a full HTML document with a minimal stylesheet.
75/// Used by [`crate::DocsServer`] to emit the right response for
76/// `GET /<page>.html`.
77#[must_use]
78pub fn to_html_document(title: &str, body: &str) -> String {
79    let fragment = to_html_fragment(body);
80    let title_escaped = html_escape(title);
81    format!(
82        r#"<!DOCTYPE html>
83<html lang="en">
84<head>
85<meta charset="utf-8">
86<meta name="viewport" content="width=device-width, initial-scale=1">
87<title>{title_escaped}</title>
88<style>
89  body {{ font-family: system-ui, -apple-system, sans-serif; max-width: 820px;
90         margin: 2rem auto; padding: 0 1rem; color: #222; line-height: 1.6; }}
91  h1, h2, h3 {{ color: #111; }}
92  pre {{ background: #f4f4f6; padding: 1rem; border-radius: 4px; overflow-x: auto; }}
93  code {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
94          background: #f4f4f6; padding: 0.1em 0.3em; border-radius: 3px; }}
95  pre code {{ background: none; padding: 0; }}
96  a {{ color: #0366d6; }}
97  table {{ border-collapse: collapse; }}
98  th, td {{ border: 1px solid #ddd; padding: 0.4rem 0.7rem; }}
99</style>
100</head>
101<body>
102{fragment}
103</body>
104</html>
105"#
106    )
107}
108
109fn html_escape(s: &str) -> String {
110    let mut out = String::with_capacity(s.len());
111    for c in s.chars() {
112        match c {
113            '&' => out.push_str("&amp;"),
114            '<' => out.push_str("&lt;"),
115            '>' => out.push_str("&gt;"),
116            '"' => out.push_str("&quot;"),
117            '\'' => out.push_str("&#x27;"),
118            other => out.push(other),
119        }
120    }
121    out
122}
123
124fn extensions() -> Options {
125    let mut opts = Options::empty();
126    opts.insert(Options::ENABLE_TABLES);
127    opts.insert(Options::ENABLE_FOOTNOTES);
128    opts.insert(Options::ENABLE_STRIKETHROUGH);
129    opts.insert(Options::ENABLE_TASKLISTS);
130    opts
131}
132
133/// Strip markdown formatting and return a plain-text approximation
134/// of the body. Used for the `tantivy` full-text index.
135#[must_use]
136pub fn to_plain_text(body: &str) -> String {
137    use pulldown_cmark::Event;
138    let mut out = String::with_capacity(body.len());
139    for event in Parser::new_ext(body, extensions()) {
140        match event {
141            Event::Text(t) | Event::Code(t) => out.push_str(&t),
142            Event::SoftBreak | Event::HardBreak => out.push(' '),
143            Event::End(_) => out.push('\n'),
144            _ => {}
145        }
146    }
147    out
148}
149
150/// Resolve a relative link inside the doc tree against `root`, going
151/// through a lexical `safe_join` check so `../../etc/passwd` is
152/// rejected before hitting the asset layer.
153#[must_use]
154pub fn resolve_link(root: &str, current_page: &str, link: &str) -> Option<String> {
155    // External links pass through verbatim.
156    if link.starts_with("http://") || link.starts_with("https://") || link.starts_with("mailto:") {
157        return Some(link.to_string());
158    }
159    // Reject absolute paths up front — both Unix (`/etc/…`) and
160    // Windows (`\foo`, `C:\foo`) shapes — before any segment work.
161    if link.starts_with('/') || std::path::Path::new(link).is_absolute() {
162        return None;
163    }
164    // Operate on `/`-joined segments directly — paths in the doc tree
165    // are URL-shaped and must not pick up the platform separator (`\`
166    // on Windows) from `PathBuf::join`.
167    let mut normalised: Vec<&str> = Vec::new();
168    let base_dir = current_page.rsplit_once('/').map_or("", |(dir, _file)| dir);
169    for seg in base_dir.split('/').filter(|s| !s.is_empty()) {
170        normalised.push(seg);
171    }
172    for seg in link.split('/') {
173        match seg {
174            "" | "." => {}
175            ".." => {
176                // Escaped root — `?` propagates `None`.
177                normalised.pop()?;
178            }
179            other => normalised.push(other),
180        }
181    }
182    let _ = root;
183    Some(normalised.join("/"))
184}
185
186// ---------------------------------------------------------------------
187// Tests
188// ---------------------------------------------------------------------
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn html_document_wraps_fragment_with_title() {
196        let html = to_html_document("My Page", "# Hello\n\nBody");
197        assert!(html.contains("<title>My Page</title>"));
198        assert!(html.contains("<h1>Hello</h1>"));
199        assert!(html.contains("Body"));
200    }
201
202    #[test]
203    fn html_escapes_title() {
204        let html = to_html_document("A <script>", "");
205        assert!(html.contains("&lt;script&gt;"));
206        assert!(!html.contains("<title>A <script>"));
207    }
208
209    #[test]
210    fn html_fragment_supports_tables() {
211        let md = "| h1 | h2 |\n|---|---|\n| a | b |";
212        let html = to_html_fragment(md);
213        assert!(html.contains("<table>"), "got: {html}");
214        assert!(html.contains("<th>h1</th>"), "got: {html}");
215    }
216
217    #[test]
218    fn plain_text_strips_formatting() {
219        let md = "# Title\n\n**Bold** and *italic*.\n\n```rust\nlet x = 1;\n```";
220        let plain = to_plain_text(md);
221        assert!(plain.contains("Title"));
222        assert!(plain.contains("Bold"));
223        assert!(plain.contains("let x = 1"));
224        assert!(!plain.contains("**"));
225    }
226
227    #[test]
228    fn resolve_link_accepts_relative() {
229        assert_eq!(resolve_link("docs", "intro.md", "install.md"), Some("install.md".into()));
230        assert_eq!(
231            resolve_link("docs", "guide/intro.md", "setup.md"),
232            Some("guide/setup.md".into())
233        );
234    }
235
236    #[test]
237    fn resolve_link_allows_parent_within_root() {
238        assert_eq!(
239            resolve_link("docs", "guide/intro.md", "../overview.md"),
240            Some("overview.md".into())
241        );
242    }
243
244    #[test]
245    fn resolve_link_rejects_escape() {
246        assert_eq!(resolve_link("docs", "intro.md", "../../etc/passwd"), None);
247    }
248
249    #[test]
250    fn resolve_link_rejects_absolute() {
251        assert_eq!(resolve_link("docs", "intro.md", "/etc/passwd"), None);
252    }
253
254    #[test]
255    fn resolve_link_passes_through_external() {
256        assert_eq!(
257            resolve_link("docs", "intro.md", "https://example.com"),
258            Some("https://example.com".into())
259        );
260    }
261
262    #[test]
263    fn to_ratatui_text_produces_non_empty_render() {
264        let text = to_ratatui_text("# Title\n\nBody").expect("render");
265        assert!(!text.lines.is_empty(), "lines should be non-empty");
266    }
267}