Skip to main content

jigs_map/
html.rs

1//! Render the live `JigMeta` inventory as a single self-contained HTML page.
2
3use jigs_core::JigMeta;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7const TEMPLATE: &str = include_str!("template.html");
8
9/// Render the pipeline rooted at `entry` (or the alphabetically first jig if
10/// `None`) as a complete HTML document. `title` is shown in the page header
11/// and `<title>` tag.
12///
13/// `editor` is an optional URL template containing `{path}` and `{line}`
14/// placeholders. When set, the sidebar's file location becomes a link using
15/// the resolved template; when `None`, it renders as plain text. Common
16/// templates:
17///
18/// - VS Code / Cursor / Windsurf: `vscode://file/{path}:{line}`
19/// - VSCodium: `vscodium://file/{path}:{line}`
20/// - JetBrains IDEs: `idea://open?file={path}&line={line}`
21/// - Sublime Text: `subl://{path}:{line}`
22/// - TextMate: `txmt://open/?url=file://{path}&line={line}`
23pub fn to_html(entry: Option<&str>, title: &str, editor: Option<&str>) -> String {
24    let all: BTreeMap<&'static str, &'static JigMeta> =
25        jigs_core::all_jigs().map(|m| (m.name, m)).collect();
26    let entry = entry
27        .map(str::to_string)
28        .or_else(|| all.keys().next().map(|s| s.to_string()))
29        .unwrap_or_default();
30    let visible = reachable(&all, &entry);
31    let data = encode(&visible, &entry, title, editor);
32    TEMPLATE
33        .replace("__TITLE__", &esc_attr(title))
34        .replace("__DATA__", &data)
35}
36
37fn reachable(
38    all: &BTreeMap<&'static str, &'static JigMeta>,
39    entry: &str,
40) -> BTreeMap<String, &'static JigMeta> {
41    let mut out = BTreeMap::new();
42    let mut stack = vec![entry.to_string()];
43    while let Some(name) = stack.pop() {
44        if out.contains_key(&name) {
45            continue;
46        }
47        if let Some(m) = all.get(name.as_str()) {
48            for c in m.chain {
49                stack.push((*c).to_string());
50            }
51            out.insert(name, *m);
52        }
53    }
54    out
55}
56
57fn encode(
58    visible: &BTreeMap<String, &'static JigMeta>,
59    entry: &str,
60    title: &str,
61    editor: Option<&str>,
62) -> String {
63    let mut s = String::new();
64    s.push_str("{\"entry\":");
65    push_json_str(&mut s, entry);
66    s.push_str(",\"title\":");
67    push_json_str(&mut s, title);
68    s.push_str(",\"editor\":");
69    match editor {
70        Some(t) => push_json_str(&mut s, t),
71        None => s.push_str("null"),
72    }
73    s.push_str(",\"nodes\":{");
74    for (i, m) in visible.values().enumerate() {
75        if i > 0 {
76            s.push(',');
77        }
78        push_json_str(&mut s, m.name);
79        s.push_str(":{\"file\":");
80        push_json_str(&mut s, m.file);
81        s.push_str(",\"line\":");
82        s.push_str(&m.line.to_string());
83        s.push_str(",\"kind\":");
84        push_json_str(&mut s, m.kind);
85        s.push_str(",\"input\":");
86        push_json_str(&mut s, m.input);
87        s.push_str(",\"async\":");
88        s.push_str(if m.is_async { "true" } else { "false" });
89        s.push_str(",\"file_abs\":");
90        push_json_str(&mut s, &absolutize(m.file));
91        s.push_str(",\"basename\":");
92        push_json_str(&mut s, basename(m.file));
93        s.push_str(",\"children\":[");
94        for (j, c) in m.chain.iter().enumerate() {
95            if j > 0 {
96                s.push(',');
97            }
98            push_json_str(&mut s, c);
99        }
100        s.push_str("]}");
101    }
102    s.push_str("}}");
103    s
104}
105
106fn push_json_str(out: &mut String, s: &str) {
107    out.push('"');
108    for ch in s.chars() {
109        match ch {
110            '"' => out.push_str("\\\""),
111            '\\' => out.push_str("\\\\"),
112            '\n' => out.push_str("\\n"),
113            '\r' => out.push_str("\\r"),
114            '\t' => out.push_str("\\t"),
115            '<' => out.push_str("\\u003c"),
116            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
117            c => out.push(c),
118        }
119    }
120    out.push('"');
121}
122
123fn basename(file: &str) -> &str {
124    Path::new(file)
125        .file_name()
126        .and_then(|n| n.to_str())
127        .unwrap_or(file)
128}
129
130fn absolutize(file: &str) -> String {
131    let p = Path::new(file);
132    if p.is_absolute() {
133        return file.to_string();
134    }
135    match std::env::current_dir() {
136        Ok(cwd) => {
137            let joined: PathBuf = cwd.join(p);
138            joined.to_string_lossy().into_owned()
139        }
140        Err(_) => file.to_string(),
141    }
142}
143
144fn esc_attr(s: &str) -> String {
145    s.replace('&', "&amp;")
146        .replace('<', "&lt;")
147        .replace('>', "&gt;")
148        .replace('"', "&quot;")
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn meta(name: &'static str, kind: &'static str, chain: &'static [&'static str]) -> JigMeta {
156        JigMeta {
157            name,
158            file: "test.rs",
159            line: 1,
160            kind,
161            input: "Request",
162            is_async: false,
163            chain,
164        }
165    }
166
167    fn fake(items: Vec<JigMeta>) -> BTreeMap<&'static str, &'static JigMeta> {
168        let leaked: Vec<&'static JigMeta> = items
169            .into_iter()
170            .map(|m| Box::leak(Box::new(m)) as &'static _)
171            .collect();
172        leaked.into_iter().map(|m| (m.name, m)).collect()
173    }
174
175    #[test]
176    fn reachable_filters_to_entry_subgraph() {
177        let all = fake(vec![
178            meta("root", "Response", &["a", "b"]),
179            meta("a", "Request", &[]),
180            meta("b", "Branch", &[]),
181            meta("orphan", "Other", &[]),
182        ]);
183        let r = reachable(&all, "root");
184        assert!(r.contains_key("root"));
185        assert!(r.contains_key("a"));
186        assert!(r.contains_key("b"));
187        assert!(!r.contains_key("orphan"));
188    }
189
190    #[test]
191    fn reachable_handles_cycles() {
192        let all = fake(vec![meta("a", "Other", &["b"]), meta("b", "Other", &["a"])]);
193        let r = reachable(&all, "a");
194        assert_eq!(r.len(), 2);
195    }
196
197    #[test]
198    fn encode_emits_structure() {
199        let all = fake(vec![
200            meta("root", "Response", &["a"]),
201            meta("a", "Request", &[]),
202        ]);
203        let visible = reachable(&all, "root");
204        let json = encode(&visible, "root", "demo", None);
205        assert!(json.contains("\"entry\":\"root\""));
206        assert!(json.contains("\"root\":{"));
207        assert!(json.contains("\"children\":[\"a\"]"));
208        assert!(json.contains("\"editor\":null"));
209    }
210
211    #[test]
212    fn editor_template_is_embedded_when_set() {
213        let all = fake(vec![meta("root", "Response", &[])]);
214        let visible = reachable(&all, "root");
215        let tmpl = "vscodium://file/{path}:{line}";
216        let json = encode(&visible, "root", "demo", Some(tmpl));
217        assert!(json.contains("\"editor\":\"vscodium://file/{path}:{line}\""));
218    }
219
220    #[test]
221    fn json_escapes_script_close() {
222        let all = fake(vec![meta("</script>", "Other", &[])]);
223        let visible = reachable(&all, "</script>");
224        let json = encode(&visible, "</script>", "t", None);
225        assert!(!json.contains("</script>"));
226        assert!(json.contains("\\u003c/script"));
227    }
228}