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.name.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.name);
99        }
100        s.push_str("],\"child_kinds\":[");
101        for (j, c) in m.chain.iter().enumerate() {
102            if j > 0 {
103                s.push(',');
104            }
105            let k = match c.kind {
106                jigs_core::ChainKind::Then => "then",
107                jigs_core::ChainKind::Fork => "fork",
108            };
109            push_json_str(&mut s, k);
110        }
111        s.push_str("]}");
112    }
113    s.push_str("}}");
114    s
115}
116
117fn push_json_str(out: &mut String, s: &str) {
118    out.push('"');
119    for ch in s.chars() {
120        match ch {
121            '"' => out.push_str("\\\""),
122            '\\' => out.push_str("\\\\"),
123            '\n' => out.push_str("\\n"),
124            '\r' => out.push_str("\\r"),
125            '\t' => out.push_str("\\t"),
126            '<' => out.push_str("\\u003c"),
127            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
128            c => out.push(c),
129        }
130    }
131    out.push('"');
132}
133
134fn basename(file: &str) -> &str {
135    Path::new(file)
136        .file_name()
137        .and_then(|n| n.to_str())
138        .unwrap_or(file)
139}
140
141fn absolutize(file: &str) -> String {
142    let p = Path::new(file);
143    if p.is_absolute() {
144        return file.to_string();
145    }
146    match std::env::current_dir() {
147        Ok(cwd) => {
148            let joined: PathBuf = cwd.join(p);
149            joined.to_string_lossy().into_owned()
150        }
151        Err(_) => file.to_string(),
152    }
153}
154
155fn esc_attr(s: &str) -> String {
156    s.replace('&', "&amp;")
157        .replace('<', "&lt;")
158        .replace('>', "&gt;")
159        .replace('"', "&quot;")
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn meta(name: &'static str, kind: &'static str, chain: &[&'static str]) -> JigMeta {
167        let v: Vec<jigs_core::ChainStep> = chain
168            .iter()
169            .map(|n| jigs_core::ChainStep {
170                name: n,
171                kind: jigs_core::ChainKind::Then,
172            })
173            .collect();
174        let leaked: &'static [jigs_core::ChainStep] = Box::leak(v.into_boxed_slice());
175        JigMeta {
176            name,
177            file: "test.rs",
178            line: 1,
179            kind,
180            input: "Request",
181            is_async: false,
182            chain: leaked,
183        }
184    }
185
186    fn fake(items: Vec<JigMeta>) -> BTreeMap<&'static str, &'static JigMeta> {
187        let leaked: Vec<&'static JigMeta> = items
188            .into_iter()
189            .map(|m| Box::leak(Box::new(m)) as &'static _)
190            .collect();
191        leaked.into_iter().map(|m| (m.name, m)).collect()
192    }
193
194    #[test]
195    fn reachable_filters_to_entry_subgraph() {
196        let all = fake(vec![
197            meta("root", "Response", &["a", "b"]),
198            meta("a", "Request", &[]),
199            meta("b", "Branch", &[]),
200            meta("orphan", "Other", &[]),
201        ]);
202        let r = reachable(&all, "root");
203        assert!(r.contains_key("root"));
204        assert!(r.contains_key("a"));
205        assert!(r.contains_key("b"));
206        assert!(!r.contains_key("orphan"));
207    }
208
209    #[test]
210    fn reachable_handles_cycles() {
211        let all = fake(vec![meta("a", "Other", &["b"]), meta("b", "Other", &["a"])]);
212        let r = reachable(&all, "a");
213        assert_eq!(r.len(), 2);
214    }
215
216    #[test]
217    fn encode_emits_structure() {
218        let all = fake(vec![
219            meta("root", "Response", &["a"]),
220            meta("a", "Request", &[]),
221        ]);
222        let visible = reachable(&all, "root");
223        let json = encode(&visible, "root", "demo", None);
224        assert!(json.contains("\"entry\":\"root\""));
225        assert!(json.contains("\"root\":{"));
226        assert!(json.contains("\"children\":[\"a\"]"));
227        assert!(json.contains("\"editor\":null"));
228    }
229
230    #[test]
231    fn editor_template_is_embedded_when_set() {
232        let all = fake(vec![meta("root", "Response", &[])]);
233        let visible = reachable(&all, "root");
234        let tmpl = "vscodium://file/{path}:{line}";
235        let json = encode(&visible, "root", "demo", Some(tmpl));
236        assert!(json.contains("\"editor\":\"vscodium://file/{path}:{line}\""));
237    }
238
239    #[test]
240    fn json_escapes_script_close() {
241        let all = fake(vec![meta("</script>", "Other", &[])]);
242        let visible = reachable(&all, "</script>");
243        let json = encode(&visible, "</script>", "t", None);
244        assert!(!json.contains("</script>"));
245        assert!(json.contains("\\u003c/script"));
246    }
247}