1use pulldown_cmark::{html::push_html, Options, Parser};
17use ratatui::text::Text;
18
19use crate::error::Result;
20
21pub fn to_ratatui_text(body: &str) -> Result<Text<'_>> {
35 Ok(tui_markdown::from_str(body))
36}
37
38#[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#[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#[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("&"),
114 '<' => out.push_str("<"),
115 '>' => out.push_str(">"),
116 '"' => out.push_str("""),
117 '\'' => out.push_str("'"),
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#[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#[must_use]
154pub fn resolve_link(root: &str, current_page: &str, link: &str) -> Option<String> {
155 if link.starts_with("http://") || link.starts_with("https://") || link.starts_with("mailto:") {
157 return Some(link.to_string());
158 }
159 if link.starts_with('/') || std::path::Path::new(link).is_absolute() {
162 return None;
163 }
164 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 normalised.pop()?;
178 }
179 other => normalised.push(other),
180 }
181 }
182 let _ = root;
183 Some(normalised.join("/"))
184}
185
186#[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("<script>"));
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}