Skip to main content

jigs_map/
html.rs

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