1use std::collections::BTreeMap;
14
15use crate::docs::escape;
16
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct ViewSource {
20 pub svg: String,
21 pub dot: Option<String>,
22}
23
24pub fn is_likec4_dot(dot: &str) -> bool {
27 dot.contains("likec4_viewId=")
28}
29
30fn is_dot_id(text: &str) -> bool {
31 !text.is_empty() && text.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
32}
33
34fn attr_value(text: &str) -> String {
37 match text.strip_prefix('"') {
38 Some(quoted) => quoted.split('"').next().unwrap_or_default().to_string(),
39 None => text
40 .split(|c: char| matches!(c, ',' | ']' | ';') || c.is_whitespace())
41 .next()
42 .unwrap_or_default()
43 .to_string(),
44 }
45}
46
47pub fn likec4_ids(dot: &str) -> BTreeMap<String, String> {
53 let mut ids = BTreeMap::new();
54 let mut current: Option<String> = None;
55 for line in dot.lines().map(str::trim) {
56 if let Some(rest) = line.strip_prefix("subgraph ") {
57 current = rest
58 .split_whitespace()
59 .next()
60 .filter(|name| is_dot_id(name))
61 .map(str::to_string);
62 } else if let Some((head, _)) = line.split_once('[') {
63 let head = head.trim();
64 if head.contains("->") {
65 current = None;
66 } else if head != "graph" && is_dot_id(head) {
67 current = Some(head.to_string());
68 }
69 }
70 if let Some(at) = line.find("likec4_id=")
71 && let Some(name) = current.take()
72 {
73 ids.insert(name, attr_value(&line[at + "likec4_id=".len()..]));
74 }
75 }
76 ids
77}
78
79fn tag_end(text: &str) -> Option<usize> {
82 let mut quoted = false;
83 for (i, c) in text.char_indices() {
84 match c {
85 '"' => quoted = !quoted,
86 '>' if !quoted => return Some(i),
87 _ => {}
88 }
89 }
90 None
91}
92
93#[derive(Debug)]
94struct Tag {
95 name: String,
96 attrs: Vec<(String, String)>,
97 self_closing: bool,
98}
99
100impl Tag {
101 fn parse(inner: &str) -> Option<Tag> {
104 let inner = inner.trim_end();
105 let (inner, self_closing) = match inner.strip_suffix('/') {
106 Some(inner) => (inner, true),
107 None => (inner, false),
108 };
109 let name_end = inner.find(char::is_whitespace).unwrap_or(inner.len());
110 let name = inner[..name_end].to_string();
111 let mut attrs = Vec::new();
112 let mut rest = inner[name_end..].trim_start();
113 while !rest.is_empty() {
114 let (key, after) = rest.split_once('=')?;
115 let quoted = after.trim_start().strip_prefix('"')?;
116 let (value, tail) = quoted.split_once('"')?;
117 attrs.push((key.trim().to_string(), value.to_string()));
118 rest = tail.trim_start();
119 }
120 Some(Tag {
121 name,
122 attrs,
123 self_closing,
124 })
125 }
126
127 fn get(&self, key: &str) -> Option<&str> {
128 self.attrs
129 .iter()
130 .find(|(k, _)| k == key)
131 .map(|(_, v)| v.as_str())
132 }
133
134 fn set(&mut self, key: &str, value: String) {
136 match self.attrs.iter_mut().find(|(k, _)| k == key) {
137 Some((_, v)) => *v = value,
138 None => self.attrs.push((key.to_string(), value)),
139 }
140 }
141
142 fn remove(&mut self, key: &str) {
143 self.attrs.retain(|(k, _)| k != key);
144 }
145
146 fn add_class(&mut self, class: &str) {
147 let classes = match self.get("class") {
148 Some(existing) => format!("{existing} {class}"),
149 None => class.to_string(),
150 };
151 self.set("class", classes);
152 }
153
154 fn write(&self, out: &mut String) -> Option<usize> {
157 out.push('<');
158 out.push_str(&self.name);
159 let mut class_end = None;
160 for (key, value) in &self.attrs {
161 out.push(' ');
162 out.push_str(key);
163 out.push_str("=\"");
164 out.push_str(value);
165 if key == "class" {
166 class_end = Some(out.len());
167 }
168 out.push('"');
169 }
170 out.push_str(if self.self_closing { "/>" } else { ">" });
171 class_end
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum Role {
177 Node,
178 Cluster,
179 Edge,
180}
181
182#[derive(Debug)]
183struct Group {
184 role: Option<Role>,
185 class_end: Option<usize>,
188 title_fill: Option<String>,
190}
191
192fn is_active(key: &str, value: &str) -> bool {
195 key.to_ascii_lowercase().starts_with("on")
196 || (key.ends_with("href")
197 && value
198 .trim_start()
199 .to_ascii_lowercase()
200 .starts_with("javascript:"))
201}
202
203pub fn inline(
208 source: &ViewSource,
209 view: &str,
210 label: &str,
211 kinds: &BTreeMap<String, String>,
212) -> Option<String> {
213 let dot = source.dot.as_deref().filter(|dot| is_likec4_dot(dot))?;
214 let ids = likec4_ids(dot);
215 let mut rest = &source.svg[source.svg.find("<svg")?..];
216 let mut out = String::with_capacity(rest.len());
217 let mut groups: Vec<Group> = Vec::new();
218 while let Some(lt) = rest.find('<') {
219 out.push_str(&rest[..lt]);
220 rest = &rest[lt..];
221 if let Some(comment) = rest.strip_prefix("<!--") {
222 rest = &comment[comment.find("-->")? + "-->".len()..];
223 continue;
224 }
225 let end = tag_end(rest)?;
226 let inner = &rest[1..end];
227 rest = &rest[end + 1..];
228 if inner.starts_with('?') || inner.starts_with('!') {
229 continue;
230 }
231 if let Some(closing) = inner.strip_prefix('/') {
232 if closing.trim() == "g" {
233 groups.pop();
234 }
235 out.push('<');
236 out.push_str(inner);
237 out.push('>');
238 continue;
239 }
240 let mut tag = Tag::parse(inner)?;
241 if tag.name == "title" && !tag.self_closing {
242 let close = rest.find("</title>")?;
243 let name = &rest[..close];
244 rest = &rest[close + "</title>".len()..];
245 if let Some(group) = groups.last_mut()
246 && let Some(at) = group.class_end.take()
247 && let Some(kind) = ids.get(name).and_then(|id| kinds.get(id))
248 {
249 out.insert_str(at, &format!(" c4-k-{kind}"));
250 }
251 continue;
252 }
253 if matches!(tag.name.as_str(), "script" | "foreignObject") {
254 if !tag.self_closing {
255 let close = format!("</{}>", tag.name);
256 rest = &rest[rest.find(&close)? + close.len()..];
257 }
258 continue;
259 }
260 tag.attrs.retain(|(key, value)| !is_active(key, value));
261 if let Some((_, id)) = tag.attrs.iter_mut().find(|(key, _)| key == "id") {
262 *id = format!("{}-{id}", escape(view));
263 }
264 let enclosing = groups.iter().rev().find_map(|g| g.role);
265 match tag.name.as_str() {
266 "svg" => {
267 tag.remove("width");
268 tag.remove("height");
269 tag.set("class", "c4".to_string());
270 tag.set("data-view", escape(view));
271 tag.set("role", "img".to_string());
272 tag.set("aria-label", escape(label));
273 }
274 "g" => {
275 let role = match tag.get("class") {
276 Some("node") => Some(Role::Node),
277 Some("cluster") => Some(Role::Cluster),
278 Some("edge") => Some(Role::Edge),
279 _ => None,
280 };
281 let class_end = tag.write(&mut out);
282 groups.push(Group {
283 role,
284 class_end: class_end
285 .filter(|_| matches!(role, Some(Role::Node | Role::Cluster))),
286 title_fill: None,
287 });
288 continue;
289 }
290 "text" if enclosing == Some(Role::Node) => {
291 let fill = tag.get("fill").unwrap_or_default().to_string();
292 if let Some(node) = groups.iter_mut().rev().find(|g| g.role.is_some()) {
293 match &node.title_fill {
294 None => node.title_fill = Some(fill),
295 Some(title) if *title != fill => tag.add_class("c4-muted"),
296 Some(_) => {}
297 }
298 }
299 }
300 "polygon" if enclosing == Some(Role::Edge) && tag.get("fill-opacity").is_some() => {
301 tag.add_class("c4-label-bg");
302 }
303 _ => {}
304 }
305 tag.write(&mut out);
306 }
307 out.push_str(rest);
308 Some(out)
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 const DOT: &str = r##"digraph {
316 graph [likec4_viewId=view_app,
317 bgcolor=transparent
318 ];
319 node [color="#2563eb",
320 fillcolor="#3b82f6"
321 ];
322 subgraph cluster_app {
323 graph [color="#57130f",
324 label=<<FONT POINT-SIZE="11">APP [x]</FONT>>,
325 likec4_id=app
326 ];
327 server [color="#2563eb",
328 likec4_id="app.server"];
329 }
330 driver_1 [likec4_id=node_driver];
331 server -> driver_1 [likec4_id="1ab"];
332}
333"##;
334
335 const SVG: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
336<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
337 "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
338<!-- Generated by graphviz -->
339<svg width="300pt" height="200pt"
340 viewBox="0.00 0.00 300.00 200.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
341<g id="graph0" class="graph" transform="scale(1 1)">
342<title>G</title>
343<g id="clust1" class="cluster">
344<title>cluster_app</title>
345<polygon fill="#722f24" stroke="#57130f" points="0,0 1,1"/>
346<text x="1" y="1" fill="#ffffe5" fill-opacity="0.7">APP</text>
347</g>
348<!-- server -->
349<g id="node1" class="node">
350<title>server</title>
351<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
352<text x="1" y="1" fill="#eff6ff">server</text>
353<text x="1" y="2" fill="#bfdbfe">Where it runs</text>
354</g>
355<g id="node2" class="node">
356<title>driver_1</title>
357<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
358<text x="1" y="1" fill="#eff6ff">node</text>
359<text x="1" y="2" fill="#eff6ff">driver</text>
360</g>
361<g id="node3" class="node">
362<title>stranger</title>
363<polygon fill="#3b82f6" stroke="#2563eb" points="0,0 1,1"/>
364</g>
365<g id="edge1" class="edge">
366<title>server->driver_1</title>
367<path fill="none" stroke="#8d8d8d" d="M0,0"/>
368<polygon fill="#8d8d8d" stroke="#8d8d8d" points="0,0 1,1"/>
369<polygon fill="#18191b" fill-opacity="0.627451" stroke="none" points="0,0 1,1"/>
370<text x="1" y="1" fill="#c9c9c9">spawns</text>
371<text x="1" y="2" fill="#aaaaaa">stdio</text>
372</g>
373</g>
374</svg>
375"##;
376
377 fn kinds() -> BTreeMap<String, String> {
378 [
379 ("app", "container"),
380 ("app.server", "component"),
381 ("node_driver", "process"),
382 ]
383 .iter()
384 .map(|(id, kind)| (id.to_string(), kind.to_string()))
385 .collect()
386 }
387
388 fn source() -> ViewSource {
389 ViewSource {
390 svg: SVG.into(),
391 dot: Some(DOT.into()),
392 }
393 }
394
395 fn inlined() -> String {
396 inline(&source(), "view_app", "app", &kinds()).expect("inlined")
397 }
398
399 #[test]
400 fn likec4_ids_maps_nodes_and_group_boxes_but_not_edges() {
401 let ids = likec4_ids(DOT);
402 let expected: BTreeMap<String, String> = [
403 ("cluster_app", "app"),
404 ("driver_1", "node_driver"),
405 ("server", "app.server"),
406 ]
407 .iter()
408 .map(|(n, id)| (n.to_string(), id.to_string()))
409 .collect();
410 assert_eq!(ids, expected);
411 }
412
413 #[test]
414 fn a_view_without_a_dot_is_not_inlined() {
415 let source = ViewSource {
416 svg: SVG.into(),
417 dot: None,
418 };
419 assert_eq!(inline(&source, "view_app", "app", &kinds()), None);
420 }
421
422 #[test]
423 fn a_view_whose_dot_likec4_did_not_write_is_not_inlined() {
424 let source = ViewSource {
425 svg: SVG.into(),
426 dot: Some("digraph { a -> b }".into()),
427 };
428 assert_eq!(inline(&source, "view_app", "app", &kinds()), None);
429 }
430
431 #[test]
432 fn the_prolog_comments_and_tooltips_are_dropped() {
433 let svg = inlined();
434 assert!(svg.starts_with("<svg "), "{svg}");
435 for gone in ["<?xml", "<!DOCTYPE", "<!--", "-->", "<title>", "graphviz"] {
436 assert!(!svg.contains(gone), "{gone} in {svg}");
437 }
438 }
439
440 #[test]
441 fn the_root_gives_way_to_the_page_and_names_the_view() {
442 let svg = inline(&source(), "view_app", "a<b", &kinds()).unwrap();
443 let root = &svg[..svg.find('>').unwrap()];
444 assert_eq!(
445 root,
446 "<svg viewBox=\"0.00 0.00 300.00 200.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" class=\"c4\" data-view=\"view_app\" role=\"img\" aria-label=\"a<b\""
447 );
448 }
449
450 #[test]
451 fn ids_carry_the_view_name() {
452 let svg = inlined();
453 assert!(svg.contains("<g id=\"view_app-node1\""), "{svg}");
454 assert!(!svg.contains("id=\"node1\""), "{svg}");
455 }
456
457 #[test]
458 fn nodes_and_group_boxes_are_classed_by_element_kind() {
459 let svg = inlined();
460 assert!(
461 svg.contains("<g id=\"view_app-clust1\" class=\"cluster c4-k-container\">"),
462 "{svg}"
463 );
464 assert!(
465 svg.contains("<g id=\"view_app-node1\" class=\"node c4-k-component\">"),
466 "{svg}"
467 );
468 assert!(
469 svg.contains("<g id=\"view_app-node2\" class=\"node c4-k-process\">"),
470 "{svg}"
471 );
472 }
473
474 #[test]
475 fn a_node_the_dot_does_not_name_keeps_its_plain_class() {
476 let svg = inlined();
477 assert!(
478 svg.contains("<g id=\"view_app-node3\" class=\"node\">"),
479 "{svg}"
480 );
481 }
482
483 #[test]
484 fn a_node_s_title_keeps_its_class_and_its_other_lines_are_muted() {
485 let svg = inlined();
486 assert!(
487 svg.contains("<text x=\"1\" y=\"1\" fill=\"#eff6ff\">server</text>"),
488 "{svg}"
489 );
490 assert!(
491 svg.contains(
492 "<text x=\"1\" y=\"2\" fill=\"#bfdbfe\" class=\"c4-muted\">Where it runs</text>"
493 ),
494 "{svg}"
495 );
496 }
497
498 #[test]
499 fn a_title_that_wraps_keeps_every_line_as_title() {
500 let svg = inlined();
501 assert!(
502 svg.contains("<text x=\"1\" y=\"2\" fill=\"#eff6ff\">driver</text>"),
503 "{svg}"
504 );
505 }
506
507 #[test]
508 fn an_edge_label_s_lines_are_never_muted() {
509 let svg = inlined();
510 assert!(
511 svg.contains("<text x=\"1\" y=\"2\" fill=\"#aaaaaa\">stdio</text>"),
512 "{svg}"
513 );
514 }
515
516 #[test]
517 fn an_edge_s_id_is_not_given_to_an_unnamed_node_before_it() {
518 let ids = likec4_ids(
519 "digraph {\n graph [likec4_viewId=v];\n helper [shape=point];\n helper -> server [likec4_id=\"1ab\"];\n}\n",
520 );
521 assert_eq!(ids, BTreeMap::new());
522 }
523
524 #[test]
525 fn a_greater_than_sign_inside_an_attribute_value_does_not_end_the_tag() {
526 let source = ViewSource {
527 svg: SVG.replace(
528 "<text x=\"1\" y=\"2\" fill=\"#bfdbfe\">",
529 "<text x=\"1\" y=\"2\" data-note=\"a>b\" fill=\"#bfdbfe\">",
530 ),
531 dot: Some(DOT.into()),
532 };
533 let svg = inline(&source, "view_app", "app", &kinds()).unwrap();
534 assert!(
535 svg.contains("data-note=\"a>b\" fill=\"#bfdbfe\" class=\"c4-muted\">"),
536 "{svg}"
537 );
538 }
539
540 #[test]
541 fn a_processing_instruction_inside_the_drawing_is_dropped() {
542 let source = ViewSource {
543 svg: SVG.replace("<g id=\"graph0\"", "<?render x?><g id=\"graph0\""),
544 dot: Some(DOT.into()),
545 };
546 let svg = inline(&source, "view_app", "app", &kinds()).unwrap();
547 assert!(!svg.contains("<?render"), "{svg}");
548 }
549
550 #[test]
551 fn an_edge_label_s_backing_is_classed_and_its_arrowhead_is_not() {
552 let svg = inlined();
553 assert!(
554 svg.contains("<polygon fill=\"#18191b\" fill-opacity=\"0.627451\" stroke=\"none\" points=\"0,0 1,1\" class=\"c4-label-bg\"/>"),
555 "{svg}"
556 );
557 assert!(
558 svg.contains("<polygon fill=\"#8d8d8d\" stroke=\"#8d8d8d\" points=\"0,0 1,1\"/>"),
559 "{svg}"
560 );
561 }
562
563 #[test]
564 fn scripts_foreign_objects_and_event_handlers_are_dropped() {
565 let svg = SVG.replace(
566 "<g id=\"edge1\" class=\"edge\">",
567 "<script>alert(1)</script><foreignObject><div>x</div></foreignObject><a xlink:href=\"javascript:alert(1)\" onclick=\"alert(1)\" href=\"#ok\"></a><g id=\"edge1\" class=\"edge\">",
568 );
569 let source = ViewSource {
570 svg,
571 dot: Some(DOT.into()),
572 };
573 let out = inline(&source, "view_app", "app", &kinds()).unwrap();
574 for gone in ["script", "alert", "foreignObject", "<div>", "onclick"] {
575 assert!(!out.contains(gone), "{gone} in {out}");
576 }
577 assert!(out.contains("<a href=\"#ok\"></a>"), "{out}");
578 }
579
580 #[test]
581 fn an_svg_that_does_not_parse_is_not_inlined() {
582 let source = ViewSource {
583 svg: "<svg viewBox=\"0 0 1 1><g>".into(),
584 dot: Some(DOT.into()),
585 };
586 assert_eq!(inline(&source, "view_app", "app", &kinds()), None);
587 }
588}