rs_web/
data.rs

1//! Tera template functions for data loading and content rendering
2
3use glob::glob;
4use pulldown_cmark::{Options, Parser, html};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use tera::{Function, Value};
8
9use crate::lua::{AssetManifest, highlight_code_sync};
10use crate::text::html_to_text;
11use crate::tracker::SharedTracker;
12
13/// Expand ~ to home directory
14fn expand_tilde(path: &str) -> PathBuf {
15    path.strip_prefix("~/")
16        .and_then(|stripped| dirs::home_dir().map(|home| home.join(stripped)))
17        .unwrap_or_else(|| PathBuf::from(path))
18}
19
20/// load_json(path) - Load and parse a JSON file
21/// Returns the parsed JSON value, or Null if file doesn't exist or is invalid
22pub fn make_load_json(tracker: Option<SharedTracker>) -> impl Function {
23    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
24        let path = args
25            .get("path")
26            .and_then(|v| v.as_str())
27            .ok_or_else(|| tera::Error::msg("load_json requires 'path' argument"))?;
28
29        let path = expand_tilde(path);
30
31        let content = match std::fs::read_to_string(&path) {
32            Ok(c) => c,
33            Err(_) => return Ok(Value::Null),
34        };
35
36        if let Some(ref tracker) = tracker {
37            let canonical = path.canonicalize().unwrap_or(path);
38            tracker.record_read(canonical, content.as_bytes());
39        }
40
41        match serde_json::from_str(&content) {
42            Ok(v) => Ok(v),
43            Err(_) => Ok(Value::Null),
44        }
45    }
46}
47
48/// read_file(path) - Read a file as text
49/// Returns the file content as string, or Null if file doesn't exist
50pub fn make_read_file(tracker: Option<SharedTracker>) -> impl Function {
51    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
52        let path = args
53            .get("path")
54            .and_then(|v| v.as_str())
55            .ok_or_else(|| tera::Error::msg("read_file requires 'path' argument"))?;
56
57        let path = expand_tilde(path);
58
59        match std::fs::read_to_string(&path) {
60            Ok(content) => {
61                if let Some(ref tracker) = tracker {
62                    let canonical = path.canonicalize().unwrap_or(path);
63                    tracker.record_read(canonical, content.as_bytes());
64                }
65                Ok(Value::String(content))
66            }
67            Err(_) => Ok(Value::Null),
68        }
69    }
70}
71
72/// read_markdown(path) - Read a Markdown file and return as HTML
73/// Returns the rendered HTML as string, or Null if file doesn't exist
74pub fn make_read_markdown(tracker: Option<SharedTracker>) -> impl Function {
75    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
76        let path = args
77            .get("path")
78            .and_then(|v| v.as_str())
79            .ok_or_else(|| tera::Error::msg("read_markdown requires 'path' argument"))?;
80
81        let path = expand_tilde(path);
82
83        match std::fs::read_to_string(&path) {
84            Ok(content) => {
85                if let Some(ref tracker) = tracker {
86                    let canonical = path.canonicalize().unwrap_or(path);
87                    tracker.record_read(canonical, content.as_bytes());
88                }
89
90                let options = Options::all();
91                let parser = Parser::new_ext(&content, options);
92                let mut html_output = String::new();
93                html::push_html(&mut html_output, parser);
94                Ok(Value::String(html_output))
95            }
96            Err(_) => Ok(Value::Null),
97        }
98    }
99}
100
101/// list_files(path, pattern?) - List files with metadata
102/// Returns array of objects: [{path, name, stem, ext}, ...]
103/// Optional pattern argument supports glob syntax (e.g., "solution.*", "*.py")
104pub fn make_list_files(_tracker: Option<SharedTracker>) -> impl Function {
105    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
106        let path = args
107            .get("path")
108            .and_then(|v| v.as_str())
109            .ok_or_else(|| tera::Error::msg("list_files requires 'path' argument"))?;
110
111        let base_path = expand_tilde(path);
112
113        // Get optional pattern, default to "*" (all files)
114        let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("*");
115
116        // Build glob pattern
117        let glob_pattern = base_path.join(pattern);
118        let glob_str = glob_pattern.to_string_lossy();
119
120        // Note: Directory listings are not tracked - changes to directory contents
121        // will trigger full rebuilds via the file watcher
122
123        let mut files: Vec<Value> = Vec::new();
124
125        if let Ok(entries) = glob(&glob_str) {
126            for entry in entries.flatten() {
127                // Skip directories
128                if entry.is_dir() {
129                    continue;
130                }
131
132                let name = entry
133                    .file_name()
134                    .and_then(|n| n.to_str())
135                    .unwrap_or("")
136                    .to_string();
137
138                let stem = entry
139                    .file_stem()
140                    .and_then(|s| s.to_str())
141                    .unwrap_or("")
142                    .to_string();
143
144                let ext = entry
145                    .extension()
146                    .and_then(|e| e.to_str())
147                    .unwrap_or("")
148                    .to_string();
149
150                let file_path = entry.to_string_lossy().to_string();
151
152                let mut file_obj = serde_json::Map::new();
153                file_obj.insert("path".to_string(), Value::String(file_path));
154                file_obj.insert("name".to_string(), Value::String(name));
155                file_obj.insert("stem".to_string(), Value::String(stem));
156                file_obj.insert("ext".to_string(), Value::String(ext));
157
158                files.push(Value::Object(file_obj));
159            }
160        }
161
162        // Sort by name for consistent ordering
163        files.sort_by(|a, b| {
164            let name_a = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
165            let name_b = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
166            name_a.cmp(name_b)
167        });
168
169        Ok(Value::Array(files))
170    }
171}
172
173/// list_dirs(path) - List subdirectories
174/// Returns array of directory names (strings)
175pub fn make_list_dirs(_tracker: Option<SharedTracker>) -> impl Function {
176    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
177        let path = args
178            .get("path")
179            .and_then(|v| v.as_str())
180            .ok_or_else(|| tera::Error::msg("list_dirs requires 'path' argument"))?;
181
182        let base_path = expand_tilde(path);
183
184        // Note: Directory listings are not tracked - changes to directory contents
185        // will trigger full rebuilds via the file watcher
186
187        let mut dirs: Vec<Value> = Vec::new();
188
189        if let Ok(entries) = std::fs::read_dir(&base_path) {
190            for entry in entries.flatten() {
191                let path = entry.path();
192                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
193                    // Skip non-directories and hidden directories
194                    if path.is_dir() && !name.starts_with('.') {
195                        dirs.push(Value::String(name.to_string()));
196                    }
197                }
198            }
199        }
200
201        // Sort for consistent ordering
202        dirs.sort_by(|a, b| {
203            let name_a = a.as_str().unwrap_or("");
204            let name_b = b.as_str().unwrap_or("");
205            name_a.cmp(name_b)
206        });
207
208        Ok(Value::Array(dirs))
209    }
210}
211
212/// markdown filter - Convert markdown text to HTML
213pub fn markdown_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
214    let text = value
215        .as_str()
216        .ok_or_else(|| tera::Error::msg("markdown filter requires a string"))?;
217
218    let options = Options::all();
219    let parser = Parser::new_ext(text, options);
220    let mut html_output = String::new();
221    html::push_html(&mut html_output, parser);
222
223    Ok(Value::String(html_output))
224}
225
226/// html_to_text filter - Convert HTML to beautifully formatted plain text
227pub fn html_to_text_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
228    let html = value
229        .as_str()
230        .ok_or_else(|| tera::Error::msg("html_to_text filter requires a string"))?;
231
232    Ok(Value::String(html_to_text(html)))
233}
234
235/// linebreaks filter - Convert newlines to <br> tags and double newlines to paragraphs
236/// Also supports basic markdown: **bold**, *label*, `code`
237pub fn linebreaks_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
238    let text = value
239        .as_str()
240        .ok_or_else(|| tera::Error::msg("linebreaks filter requires a string"))?;
241
242    // Split by double newlines for paragraphs
243    let paragraphs: Vec<String> = text
244        .split("\n\n")
245        .map(|p| {
246            let p = p.replace('\n', "<br>");
247            // Convert **bold** to <strong> (must be before single *)
248            let p = convert_bold(&p);
249            // Convert *label* to <span class="label">
250            let p = convert_label(&p);
251            // Convert `code` to <code>
252            convert_code(&p)
253        })
254        .collect();
255
256    let html = format!("<p>{}</p>", paragraphs.join("</p><p>"));
257    Ok(Value::String(html))
258}
259
260/// Convert **text** to <strong>text</strong>
261#[allow(clippy::while_let_on_iterator)]
262fn convert_bold(text: &str) -> String {
263    let mut result = String::new();
264    let mut chars = text.chars().peekable();
265
266    while let Some(c) = chars.next() {
267        if c == '*' && chars.peek() == Some(&'*') {
268            chars.next(); // consume second *
269            // Find closing **
270            let mut bold_text = String::new();
271            let mut found_close = false;
272            while let Some(bc) = chars.next() {
273                if bc == '*' && chars.peek() == Some(&'*') {
274                    chars.next(); // consume second *
275                    found_close = true;
276                    break;
277                }
278                bold_text.push(bc);
279            }
280            if found_close {
281                result.push_str(&format!("<strong>{}</strong>", bold_text));
282            } else {
283                result.push_str("**");
284                result.push_str(&bold_text);
285            }
286        } else {
287            result.push(c);
288        }
289    }
290    result
291}
292
293/// Convert *text* to <span class="label">text</span>
294#[allow(clippy::while_let_on_iterator)]
295fn convert_label(text: &str) -> String {
296    let mut result = String::new();
297    let mut chars = text.chars().peekable();
298
299    while let Some(c) = chars.next() {
300        if c == '*' {
301            // Find closing *
302            let mut label_text = String::new();
303            let mut found_close = false;
304            while let Some(lc) = chars.next() {
305                if lc == '*' {
306                    found_close = true;
307                    break;
308                }
309                label_text.push(lc);
310            }
311            if found_close {
312                result.push_str(&format!("<span class=\"label\">{}</span>", label_text));
313            } else {
314                result.push('*');
315                result.push_str(&label_text);
316            }
317        } else {
318            result.push(c);
319        }
320    }
321    result
322}
323
324/// Convert `text` to <code>text</code>
325#[allow(clippy::while_let_on_iterator)]
326fn convert_code(text: &str) -> String {
327    let mut result = String::new();
328    let mut chars = text.chars().peekable();
329
330    while let Some(c) = chars.next() {
331        if c == '`' {
332            // Find closing `
333            let mut code_text = String::new();
334            let mut found_close = false;
335            while let Some(cc) = chars.next() {
336                if cc == '`' {
337                    found_close = true;
338                    break;
339                }
340                code_text.push(cc);
341            }
342            if found_close {
343                result.push_str(&format!("<code>{}</code>", code_text));
344            } else {
345                result.push('`');
346                result.push_str(&code_text);
347            }
348        } else {
349            result.push(c);
350        }
351    }
352    result
353}
354
355/// highlight filter - Syntax highlight code with a specified language
356/// Usage: {{ code | highlight(lang="python") }}
357pub fn highlight_filter(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
358    let code = value
359        .as_str()
360        .ok_or_else(|| tera::Error::msg("highlight filter requires a string"))?;
361
362    let lang = args.get("lang").and_then(|v| v.as_str()).unwrap_or("text");
363
364    let highlighted = highlight_code_sync(code, lang);
365    Ok(Value::String(highlighted))
366}
367
368/// Create the asset filter that looks up hashed paths from the manifest
369/// Usage: {{ "/styles/main.css" | asset }}
370/// Returns the hashed path if found, otherwise returns the original path
371pub fn make_asset_filter(
372    manifest: AssetManifest,
373) -> impl Fn(&Value, &HashMap<String, Value>) -> tera::Result<Value> {
374    move |value: &Value, _args: &HashMap<String, Value>| {
375        let path = value
376            .as_str()
377            .ok_or_else(|| tera::Error::msg("asset filter requires a string path"))?;
378
379        // Normalize path to have leading slash
380        let normalized = if path.starts_with('/') {
381            path.to_string()
382        } else {
383            format!("/{}", path)
384        };
385
386        // Look up in manifest, return original if not found
387        let result = manifest
388            .get(&normalized)
389            .map(|v| v.clone())
390            .unwrap_or_else(|| path.to_string());
391
392        Ok(Value::String(result))
393    }
394}
395
396/// Register all data functions with Tera
397pub fn register_data_functions(
398    tera: &mut tera::Tera,
399    tracker: Option<SharedTracker>,
400    asset_manifest: Option<AssetManifest>,
401) {
402    tera.register_function("load_json", make_load_json(tracker.clone()));
403    tera.register_function("read_file", make_read_file(tracker.clone()));
404    tera.register_function("read_markdown", make_read_markdown(tracker.clone()));
405    tera.register_function("list_files", make_list_files(tracker.clone()));
406    tera.register_function("list_dirs", make_list_dirs(tracker));
407    tera.register_filter("markdown", markdown_filter);
408    tera.register_filter("linebreaks", linebreaks_filter);
409    tera.register_filter("html_to_text", html_to_text_filter);
410    tera.register_filter("highlight", highlight_filter);
411
412    // Register asset filter if manifest is provided
413    if let Some(manifest) = asset_manifest {
414        tera.register_filter("asset", make_asset_filter(manifest));
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::fs;
422    use tempfile::tempdir;
423    use tera::Function;
424
425    #[test]
426    fn test_expand_tilde() {
427        let path = expand_tilde("~/test");
428        assert!(path.to_string_lossy().contains("test"));
429        assert!(!path.to_string_lossy().starts_with("~"));
430
431        let path = expand_tilde("/absolute/path");
432        assert_eq!(path, PathBuf::from("/absolute/path"));
433    }
434
435    #[test]
436    fn test_load_json() {
437        let dir = tempdir().unwrap();
438        let json_path = dir.path().join("test.json");
439        fs::write(&json_path, r#"{"name": "test", "value": 42}"#).unwrap();
440
441        let func = make_load_json(None);
442        let mut args = HashMap::new();
443        args.insert(
444            "path".to_string(),
445            Value::String(json_path.to_string_lossy().to_string()),
446        );
447
448        let result = func.call(&args).unwrap();
449        assert_eq!(result.get("name").unwrap().as_str().unwrap(), "test");
450        assert_eq!(result.get("value").unwrap().as_i64().unwrap(), 42);
451    }
452
453    #[test]
454    fn test_load_json_missing_file() {
455        let func = make_load_json(None);
456        let mut args = HashMap::new();
457        args.insert(
458            "path".to_string(),
459            Value::String("/nonexistent/path.json".to_string()),
460        );
461
462        let result = func.call(&args).unwrap();
463        assert!(result.is_null());
464    }
465
466    #[test]
467    fn test_read_file() {
468        let dir = tempdir().unwrap();
469        let file_path = dir.path().join("test.txt");
470        fs::write(&file_path, "Hello, World!").unwrap();
471
472        let func = make_read_file(None);
473        let mut args = HashMap::new();
474        args.insert(
475            "path".to_string(),
476            Value::String(file_path.to_string_lossy().to_string()),
477        );
478
479        let result = func.call(&args).unwrap();
480        assert_eq!(result.as_str().unwrap(), "Hello, World!");
481    }
482
483    #[test]
484    fn test_read_file_missing() {
485        let func = make_read_file(None);
486        let mut args = HashMap::new();
487        args.insert(
488            "path".to_string(),
489            Value::String("/nonexistent/file.txt".to_string()),
490        );
491
492        let result = func.call(&args).unwrap();
493        assert!(result.is_null());
494    }
495
496    #[test]
497    fn test_list_files() {
498        let dir = tempdir().unwrap();
499        fs::write(dir.path().join("solution.py"), "print('hello')").unwrap();
500        fs::write(dir.path().join("solution.cpp"), "int main(){}").unwrap();
501        fs::write(dir.path().join("README.md"), "# Hello").unwrap();
502
503        let func = make_list_files(None);
504        let mut args = HashMap::new();
505        args.insert(
506            "path".to_string(),
507            Value::String(dir.path().to_string_lossy().to_string()),
508        );
509
510        let result = func.call(&args).unwrap();
511        let files = result.as_array().unwrap();
512        assert_eq!(files.len(), 3);
513
514        // Test with pattern
515        args.insert(
516            "pattern".to_string(),
517            Value::String("solution.*".to_string()),
518        );
519        let result = func.call(&args).unwrap();
520        let files = result.as_array().unwrap();
521        assert_eq!(files.len(), 2);
522
523        // Check file object structure
524        let file = &files[0];
525        assert!(file.get("path").is_some());
526        assert!(file.get("name").is_some());
527        assert!(file.get("stem").is_some());
528        assert!(file.get("ext").is_some());
529    }
530
531    #[test]
532    fn test_list_dirs() {
533        let dir = tempdir().unwrap();
534        fs::create_dir(dir.path().join("subdir1")).unwrap();
535        fs::create_dir(dir.path().join("subdir2")).unwrap();
536        fs::create_dir(dir.path().join(".hidden")).unwrap();
537        fs::write(dir.path().join("file.txt"), "not a dir").unwrap();
538
539        let func = make_list_dirs(None);
540        let mut args = HashMap::new();
541        args.insert(
542            "path".to_string(),
543            Value::String(dir.path().to_string_lossy().to_string()),
544        );
545
546        let result = func.call(&args).unwrap();
547        let dirs = result.as_array().unwrap();
548        assert_eq!(dirs.len(), 2); // Should exclude .hidden
549        assert!(dirs.contains(&Value::String("subdir1".to_string())));
550        assert!(dirs.contains(&Value::String("subdir2".to_string())));
551    }
552}