lib/zen/src/diagram/ego/svg.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2
3 const root = @import("root.zig");
4 const zen = @import("../../root.zig");
5
6 const html = zen.html;
7 const layout = root.layout;
8 const model = root.model;
9
10 pub const output_bytes_max: usize = 96 * 1024;
11 pub const document_fragment_bytes_max: usize = output_bytes_max + 1024;
12 const escaped_bytes_max: usize = model.href_bytes_max * 6;
13
14 pub const FragmentError = error{
15 InvalidDocumentFragment,
16 UnsafeDocumentFragment,
17 };
18
19 const stylesheet =
20 \\.zen-ego{color:var(--ink,#1a1714);overflow:visible}
21 \\.zen-ego text{
22 \\font-family:var(--face-mono,ui-monospace,monospace);
23 \\pointer-events:none
24 \\}
25 \\.zen-ego-edge{
26 \\fill:none;
27 \\stroke:var(--rule-bold,rgba(26,23,20,.3));
28 \\stroke-width:1.5;
29 \\vector-effect:non-scaling-stroke
30 \\}
31 \\.zen-ego-edge-more{stroke-dasharray:4 4}
32 \\.zen-ego-arrow{fill:var(--ink-mute,#8c8275)}
33 \\.zen-ego-heading{
34 \\fill:var(--ink-soft,#564e44);
35 \\font-weight:700;
36 \\letter-spacing:.08em;
37 \\text-transform:uppercase
38 \\}
39 \\.zen-ego-empty{fill:var(--ink-mute,#8c8275);font-style:italic}
40 \\.zen-ego-node>rect{
41 \\fill:var(--paper,#f4f1ea);
42 \\stroke:var(--rule-bold,rgba(26,23,20,.3));
43 \\stroke-width:1.25;
44 \\vector-effect:non-scaling-stroke
45 \\}
46 \\.zen-ego-node .zen-ego-label{fill:var(--ink,#1a1714);font-weight:700}
47 \\.zen-ego-node .zen-ego-context{fill:var(--ink-soft,#564e44)}
48 \\.zen-ego .zen-ego-node{text-decoration:none}
49 \\.zen-ego-subject>rect{
50 \\fill:var(--paper-sunk,#ece8dd);
51 \\stroke:var(--accent-ink,#d2371f);
52 \\stroke-width:2.25
53 \\}
54 \\.zen-ego-subject .zen-ego-label{font-weight:900}
55 \\.zen-ego-more>rect{fill:transparent;stroke-dasharray:4 3}
56 \\.zen-ego-badge rect{
57 \\fill:var(--paper-raised,var(--paper,#f4f1ea));
58 \\stroke:var(--rule-bold,rgba(26,23,20,.3));
59 \\stroke-width:1
60 \\}
61 \\.zen-ego-badge text{
62 \\fill:var(--ink-soft,#564e44);
63 \\font-weight:800;
64 \\letter-spacing:.04em
65 \\}
66 \\.zen-ego-self-loop{stroke:var(--accent-ink,#d2371f);stroke-width:2}
67 \\a.zen-ego-node:is(:hover,:focus-visible)>rect{
68 \\stroke:var(--accent-ink,#d2371f);
69 \\stroke-width:2.5
70 \\}
71 \\a.zen-ego-node:is(:hover,:focus-visible) .zen-ego-label{
72 \\fill:var(--accent-ink,#d2371f);
73 \\text-decoration:underline
74 \\}
75 \\a.zen-ego-node:focus-visible{outline:none}
76 \\a.zen-ego-node:focus-visible .zen-ego-badge rect{
77 \\stroke:var(--accent-ink,#d2371f)
78 \\}
79 \\.zen-ego-node-plain>rect{stroke-dasharray:3 3}
80 ;
81
82 /// Renders one call map into caller-owned output storage.
83 pub fn render(
84 output: []u8,
85 network: model.Network,
86 surface: model.Surface,
87 ) (model.Error || std.Io.Writer.Error)![]const u8 {
88 var writer = std.Io.Writer.fixed(output);
89 try write(&writer, network, surface);
90 return writer.buffered();
91 }
92
93 /// Writes one bounded, linked, static SVG call map.
94 pub fn write(
95 writer: *std.Io.Writer,
96 network: model.Network,
97 surface: model.Surface,
98 ) (model.Error || std.Io.Writer.Error)!void {
99 const plan = try layout.Plan.inspect(network, surface);
100 const leaf = network.callers.total == 0 and network.callees.total == 0;
101 try writeOpen(writer, network, plan.geometry, leaf);
102 if (!(leaf and plan.geometry.arrangement == .horizontal)) {
103 try writeHeaders(writer, &plan);
104 }
105 try writeConnections(
106 writer,
107 &plan.callers,
108 plan.subject,
109 plan.geometry,
110 .callers,
111 );
112 try writeConnections(
113 writer,
114 &plan.callees,
115 plan.subject,
116 plan.geometry,
117 .callees,
118 );
119 try writeEmptyStates(writer, &plan);
120 try writeBand(writer, &plan.callers, plan.geometry, .callers);
121 try writeBand(writer, &plan.callees, plan.geometry, .callees);
122 if (network.recursive) try writeSelfLoop(writer, plan.subject);
123 try writeNode(writer, network.subject, plan.subject, plan.geometry, true);
124 try writer.writeAll("</svg>");
125 }
126
127 /// Validates the documentation fragment envelope around one rendered call map.
128 pub fn validateDocumentFragment(source: []const u8) FragmentError!void {
129 const fragment = std.mem.trim(u8, source, " \t\r\n");
130 if (fragment.len == 0 or fragment.len > document_fragment_bytes_max) {
131 return error.InvalidDocumentFragment;
132 }
133 const wide =
134 "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide";
135 const anchored =
136 "<figure class=\"docs-call-map docs-call-map-anchored zen-role-wide";
137 if (!std.mem.startsWith(u8, fragment, wide) and
138 !std.mem.startsWith(u8, fragment, anchored))
139 {
140 return error.InvalidDocumentFragment;
141 }
142 if (!std.mem.endsWith(u8, fragment, "</figcaption></figure>")) {
143 return error.InvalidDocumentFragment;
144 }
145 if (!std.unicode.utf8ValidateSlice(fragment) or
146 containsAsciiInsensitive(fragment, "<script") or
147 containsAsciiInsensitive(fragment, "<foreignobject") or
148 containsEventAttribute(fragment) or
149 containsAsciiInsensitive(fragment, " style=") or
150 containsAsciiInsensitive(fragment, "xlink:href") or
151 containsAsciiInsensitive(fragment, "javascript:") or
152 containsAsciiInsensitive(fragment, "data:") or
153 containsAsciiInsensitive(fragment, "url(") or
154 containsAsciiInsensitive(fragment, "@import"))
155 {
156 return error.UnsafeDocumentFragment;
157 }
158 try validateFragmentTags(fragment);
159 try validateFragmentLinks(fragment);
160 }
161
162 fn validateFragmentTags(fragment: []const u8) FragmentError!void {
163 const prefixes = [_][]const u8{
164 "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide",
165 "<figure class=\"docs-call-map docs-call-map-anchored zen-role-wide",
166 "</figure>",
167 "<svg ",
168 "</svg>",
169 "<style>",
170 "</style>",
171 "<path ",
172 "<text ",
173 "</text>",
174 "<a ",
175 "</a>",
176 "<rect ",
177 "<g ",
178 "</g>",
179 "<figcaption>",
180 "</figcaption>",
181 };
182 var cursor: usize = 0;
183 while (std.mem.indexOfScalarPos(u8, fragment, cursor, '<')) |start| {
184 const end = std.mem.indexOfScalarPos(u8, fragment, start, '>') orelse
185 return error.InvalidDocumentFragment;
186 var admitted = false;
187 for (prefixes) |prefix| {
188 admitted = admitted or std.mem.startsWith(u8, fragment[start..], prefix);
189 }
190 if (!admitted) return error.UnsafeDocumentFragment;
191 cursor = end + 1;
192 }
193 if (std.mem.count(u8, fragment, "<svg ") != 1 or
194 std.mem.count(u8, fragment, "</svg>") != 1)
195 {
196 return error.InvalidDocumentFragment;
197 }
198 }
199
200 fn validateFragmentLinks(fragment: []const u8) FragmentError!void {
201 const marker = " href=\"";
202 var cursor: usize = 0;
203 while (std.mem.indexOfPos(u8, fragment, cursor, marker)) |start| {
204 const value_start = start + marker.len;
205 if (value_start == fragment.len) return error.InvalidDocumentFragment;
206 const end = std.mem.indexOfScalarPos(u8, fragment, value_start, '"') orelse
207 return error.InvalidDocumentFragment;
208 if (end == value_start) return error.InvalidDocumentFragment;
209 if (!model.safeHref(fragment[value_start..end])) {
210 return error.UnsafeDocumentFragment;
211 }
212 cursor = end + 1;
213 }
214 }
215
216 fn containsAsciiInsensitive(haystack: []const u8, needle: []const u8) bool {
217 if (needle.len == 0 or needle.len > haystack.len) return false;
218 return std.ascii.findIgnoreCase(haystack, needle) != null;
219 }
220
221 fn containsEventAttribute(fragment: []const u8) bool {
222 var tag_start: usize = 0;
223 while (std.mem.indexOfScalarPos(u8, fragment, tag_start, '<')) |start| {
224 const end = std.mem.indexOfScalarPos(u8, fragment, start, '>') orelse
225 return true;
226 var cursor = start + 1;
227 while (cursor + 3 < end) : (cursor += 1) {
228 if (!std.ascii.isWhitespace(fragment[cursor])) continue;
229 const name_start = cursor + 1;
230 if (std.ascii.toLower(fragment[name_start]) != 'o' or
231 std.ascii.toLower(fragment[name_start + 1]) != 'n') continue;
232 var name_end = name_start + 2;
233 while (name_end < end and
234 (std.ascii.isAlphanumeric(fragment[name_end]) or
235 fragment[name_end] == '-' or fragment[name_end] == '_'))
236 {
237 name_end += 1;
238 }
239 while (name_end < end and std.ascii.isWhitespace(fragment[name_end])) {
240 name_end += 1;
241 }
242 if (name_end < end and fragment[name_end] == '=') return true;
243 }
244 tag_start = end + 1;
245 }
246 return false;
247 }
248
249 const Direction = enum {
250 callers,
251 callees,
252 };
253
254 fn writeOpen(
255 writer: *std.Io.Writer,
256 network: model.Network,
257 geometry: model.Geometry,
258 leaf: bool,
259 ) std.Io.Writer.Error!void {
260 try writer.print(
261 "<svg xmlns=\"http://www.w3.org/2000/svg\" " ++
262 "class=\"zen-ego{s}\" viewBox=\"0 0 {d} {d}\" " ++
263 "role=\"group\" aria-label=\"Static call map for ",
264 .{
265 if (leaf) " zen-ego-leaf" else "",
266 geometry.width,
267 geometry.height,
268 },
269 );
270 try writeAccessibleName(writer, network.subject);
271 try writer.writeAll(if (leaf and geometry.arrangement == .horizontal)
272 ". It has no direct callers and no direct calls."
273 else switch (geometry.arrangement) {
274 .horizontal => ". Callers flow in from the left. Calls flow out to the right.",
275 .vertical => ". Callers flow in from above. Calls flow out below.",
276 });
277 if (network.recursive) try writer.writeAll(" It calls itself.");
278 try writer.writeAll("\"><style>");
279 try writer.writeAll(stylesheet);
280 try writer.writeAll("</style>");
281 }
282
283 fn writeHeaders(
284 writer: *std.Io.Writer,
285 plan: *const layout.Plan,
286 ) std.Io.Writer.Error!void {
287 try writeHeader(
288 writer,
289 plan.callers.heading.x,
290 plan.callers.heading.y,
291 plan.geometry.heading_size,
292 "Called by",
293 );
294 try writeHeader(
295 writer,
296 plan.callees.heading.x,
297 plan.callees.heading.y,
298 plan.geometry.heading_size,
299 "Calls",
300 );
301 }
302
303 fn writeHeader(
304 writer: *std.Io.Writer,
305 x: f32,
306 y: f32,
307 size: f32,
308 value: []const u8,
309 ) std.Io.Writer.Error!void {
310 try writer.print(
311 "<text class=\"zen-ego-heading\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
312 "font-size=\"{d:.2}\" text-anchor=\"middle\">{s}</text>",
313 .{ x, y, size, value },
314 );
315 }
316
317 fn writeConnections(
318 writer: *std.Io.Writer,
319 band: *const layout.Band,
320 subject: layout.Rect,
321 geometry: model.Geometry,
322 direction: Direction,
323 ) std.Io.Writer.Error!void {
324 std.debug.assert(band.count <= model.max_slots_per_band);
325 for (band.slice()) |item| {
326 const dashed = switch (item.content) {
327 .node => false,
328 .more => true,
329 };
330 switch (geometry.arrangement) {
331 .horizontal => try writeHorizontalConnection(
332 writer,
333 item.box,
334 subject,
335 direction,
336 dashed,
337 ),
338 .vertical => try writeVerticalConnection(
339 writer,
340 item.box,
341 direction,
342 dashed,
343 ),
344 }
345 }
346 }
347
348 fn writeHorizontalConnection(
349 writer: *std.Io.Writer,
350 item: layout.Rect,
351 subject: layout.Rect,
352 direction: Direction,
353 dashed: bool,
354 ) std.Io.Writer.Error!void {
355 const start_x = if (direction == .callers)
356 item.x + item.width + 4
357 else
358 subject.x + subject.width + 4;
359 const end_x = if (direction == .callers) subject.x - 4 else item.x - 4;
360 const start_y = if (direction == .callers) item.centerY() else subject.centerY();
361 const end_y = if (direction == .callers) subject.centerY() else item.centerY();
362 try writeHorizontalEdge(writer, start_x, start_y, end_x, end_y, dashed);
363 }
364
365 fn writeHorizontalEdge(
366 writer: *std.Io.Writer,
367 start_x: f32,
368 start_y: f32,
369 end_x: f32,
370 end_y: f32,
371 dashed: bool,
372 ) std.Io.Writer.Error!void {
373 const middle = (start_x + end_x) / 2;
374 try writer.print(
375 "<path class=\"zen-ego-edge{s}\" d=\"M {d:.2} {d:.2} " ++
376 "C {d:.2} {d:.2}, {d:.2} {d:.2}, {d:.2} {d:.2}\"/>",
377 .{
378 if (dashed) " zen-ego-edge-more" else "",
379 start_x,
380 start_y,
381 middle,
382 start_y,
383 middle,
384 end_y,
385 end_x,
386 end_y,
387 },
388 );
389 try writer.print(
390 "<path class=\"zen-ego-arrow\" d=\"M {d:.2} {d:.2} " ++
391 "L {d:.2} {d:.2} L {d:.2} {d:.2} Z\"/>",
392 .{ end_x, end_y, end_x - 7, end_y - 4, end_x - 7, end_y + 4 },
393 );
394 }
395
396 fn writeVerticalConnection(
397 writer: *std.Io.Writer,
398 item: layout.Rect,
399 direction: Direction,
400 dashed: bool,
401 ) std.Io.Writer.Error!void {
402 const start_y = if (direction == .callers)
403 item.y + item.height + 1
404 else
405 item.y - 10;
406 const end_y = if (direction == .callers) start_y + 8 else item.y - 2;
407 try writeVerticalStub(writer, item.centerX(), start_y, end_y, dashed);
408 }
409
410 fn writeVerticalStub(
411 writer: *std.Io.Writer,
412 x: f32,
413 start_y: f32,
414 end_y: f32,
415 dashed: bool,
416 ) std.Io.Writer.Error!void {
417 std.debug.assert(start_y < end_y);
418 try writer.print(
419 "<path class=\"zen-ego-edge{s}\" d=\"M {d:.2} {d:.2} L {d:.2} {d:.2}\"/>",
420 .{
421 if (dashed) " zen-ego-edge-more" else "",
422 x,
423 start_y,
424 x,
425 end_y,
426 },
427 );
428 try writer.print(
429 "<path class=\"zen-ego-arrow\" d=\"M {d:.2} {d:.2} " ++
430 "L {d:.2} {d:.2} L {d:.2} {d:.2} Z\"/>",
431 .{ x, end_y, x - 4, end_y - 7, x + 4, end_y - 7 },
432 );
433 }
434
435 fn writeSelfLoop(
436 writer: *std.Io.Writer,
437 subject: layout.Rect,
438 ) std.Io.Writer.Error!void {
439 const start_x = subject.centerX() - 18;
440 const end_x = subject.centerX() + 18;
441 const end_y = subject.y - 3;
442 const top_y = end_y - 22;
443 try writer.print(
444 "<path class=\"zen-ego-edge zen-ego-self-loop\" aria-hidden=\"true\" " ++
445 "d=\"M {d:.2} {d:.2} C {d:.2} {d:.2}, {d:.2} {d:.2}, " ++
446 "{d:.2} {d:.2}\"/>",
447 .{ start_x, end_y, start_x, top_y, end_x, top_y, end_x, end_y },
448 );
449 try writer.print(
450 "<path class=\"zen-ego-arrow\" aria-hidden=\"true\" " ++
451 "d=\"M {d:.2} {d:.2} L {d:.2} {d:.2} L {d:.2} {d:.2} Z\"/>",
452 .{ end_x, end_y, end_x - 4, end_y - 7, end_x + 4, end_y - 7 },
453 );
454 }
455
456 fn writeEmptyStates(
457 writer: *std.Io.Writer,
458 plan: *const layout.Plan,
459 ) std.Io.Writer.Error!void {
460 const geometry = plan.geometry;
461 if (plan.callers.count == 0) {
462 try writeEmpty(
463 writer,
464 plan.callers.empty.x,
465 plan.callers.empty.y,
466 geometry.empty_size,
467 "No direct callers",
468 );
469 }
470 if (plan.callees.count == 0) {
471 try writeEmpty(
472 writer,
473 plan.callees.empty.x,
474 plan.callees.empty.y,
475 geometry.empty_size,
476 "No direct calls",
477 );
478 }
479 }
480
481 fn writeEmpty(
482 writer: *std.Io.Writer,
483 x: f32,
484 y: f32,
485 size: f32,
486 value: []const u8,
487 ) std.Io.Writer.Error!void {
488 try writer.print(
489 "<text class=\"zen-ego-empty\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
490 "font-size=\"{d:.2}\" text-anchor=\"middle\" " ++
491 "dominant-baseline=\"middle\">{s}</text>",
492 .{ x, y, size, value },
493 );
494 }
495
496 fn writeBand(
497 writer: *std.Io.Writer,
498 band: *const layout.Band,
499 geometry: model.Geometry,
500 direction: Direction,
501 ) std.Io.Writer.Error!void {
502 std.debug.assert(band.count <= model.max_slots_per_band);
503 for (band.slice()) |item| switch (item.content) {
504 .node => |node| try writeNode(writer, node, item.box, geometry, false),
505 .more => |more| try writeMore(writer, more, item.box, geometry, direction),
506 };
507 }
508
509 fn writeNode(
510 writer: *std.Io.Writer,
511 node: model.Node,
512 box: layout.Rect,
513 geometry: model.Geometry,
514 subject: bool,
515 ) std.Io.Writer.Error!void {
516 const linked = node.href.len != 0;
517 try writer.writeAll(if (linked)
518 "<a class=\"zen-ego-node"
519 else
520 "<g class=\"zen-ego-node zen-ego-node-plain");
521 if (subject) try writer.writeAll(" zen-ego-subject");
522 if (linked) {
523 try writer.writeAll("\" href=\"");
524 try writeEscaped(writer, node.href);
525 try writer.writeAll("\" tabindex=\"0");
526 } else {
527 try writer.writeAll("\" role=\"img");
528 }
529 try writer.writeAll("\" aria-label=\"");
530 try writeAccessibleName(writer, node);
531 try writer.writeAll("\">");
532 try writeBox(writer, box);
533 const label_size = if (subject) geometry.subject_label_size else geometry.label_size;
534 if (node.badge.len > 0) try writeBadge(writer, node.badge, box, geometry.badge_size);
535 try writeNodeText(writer, node, box, label_size, geometry.context_size);
536 try writer.writeAll(if (linked) "</a>" else "</g>");
537 }
538
539 fn writeMore(
540 writer: *std.Io.Writer,
541 more: layout.More,
542 box: layout.Rect,
543 geometry: model.Geometry,
544 direction: Direction,
545 ) std.Io.Writer.Error!void {
546 try writer.writeAll(
547 "<a class=\"zen-ego-node zen-ego-more\" href=\"",
548 );
549 try writeEscaped(writer, more.href);
550 try writer.print("\" tabindex=\"0\" aria-label=\"{d} more direct {s}\">", .{
551 more.count,
552 if (direction == .callers) "callers" else "calls",
553 });
554 try writeBox(writer, box);
555 try writer.print(
556 "<text class=\"zen-ego-label\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
557 "font-size=\"{d:.2}\" text-anchor=\"middle\" " ++
558 "dominant-baseline=\"middle\">+{d} more</text></a>",
559 .{ box.centerX(), box.centerY(), geometry.label_size, more.count },
560 );
561 }
562
563 fn writeBox(
564 writer: *std.Io.Writer,
565 box: layout.Rect,
566 ) std.Io.Writer.Error!void {
567 try writer.print(
568 "<rect x=\"{d:.2}\" y=\"{d:.2}\" width=\"{d:.2}\" " ++
569 "height=\"{d:.2}\" rx=\"8\"/>",
570 .{ box.x, box.y, box.width, box.height },
571 );
572 }
573
574 fn writeNodeText(
575 writer: *std.Io.Writer,
576 node: model.Node,
577 box: layout.Rect,
578 label_size: f32,
579 context_size: f32,
580 ) std.Io.Writer.Error!void {
581 const center_x = box.centerX();
582 const center_y = box.centerY();
583 if (node.context.len == 0) {
584 try writeText(
585 writer,
586 "zen-ego-label",
587 node.label,
588 center_x,
589 center_y + if (node.badge.len > 0) @as(f32, 7) else 0,
590 label_size,
591 box.width - 14,
592 );
593 return;
594 }
595 const context_y = if (node.badge.len > 0) center_y + 2 else center_y - 9;
596 const label_y = if (node.badge.len > 0) center_y + 15 else center_y + 10;
597 try writeText(
598 writer,
599 "zen-ego-context",
600 node.context,
601 center_x,
602 context_y,
603 context_size,
604 box.width - 14,
605 );
606 try writeText(
607 writer,
608 "zen-ego-label",
609 node.label,
610 center_x,
611 label_y,
612 label_size,
613 box.width - 14,
614 );
615 }
616
617 fn writeBadge(
618 writer: *std.Io.Writer,
619 value: []const u8,
620 box: layout.Rect,
621 size: f32,
622 ) std.Io.Writer.Error!void {
623 const glyphs = std.unicode.utf8CountCodepoints(value) catch unreachable;
624 const desired = @as(f32, @floatFromInt(glyphs)) * size * 0.62 + 12;
625 const width = @min(box.width - 12, @max(@as(f32, 34), desired));
626 const x = box.x + box.width - width - 6;
627 const y = box.y + 5;
628 try writer.print(
629 "<g class=\"zen-ego-badge\"><rect x=\"{d:.2}\" y=\"{d:.2}\" " ++
630 "width=\"{d:.2}\" height=\"14\" rx=\"7\"/>",
631 .{ x, y, width },
632 );
633 try writeText(writer, "zen-ego-badge-label", value, x + width / 2, y + 7, size, width - 8);
634 try writer.writeAll("</g>");
635 }
636
637 fn writeText(
638 writer: *std.Io.Writer,
639 class: []const u8,
640 value: []const u8,
641 x: f32,
642 y: f32,
643 size: f32,
644 width: f32,
645 ) std.Io.Writer.Error!void {
646 try writer.print(
647 "<text class=\"{s}\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
648 "font-size=\"{d:.2}\" text-anchor=\"middle\" " ++
649 "dominant-baseline=\"middle\"",
650 .{ class, x, y, size },
651 );
652 const glyphs = std.unicode.utf8CountCodepoints(value) catch unreachable;
653 if (@as(f32, @floatFromInt(glyphs)) * size * 0.62 > width) {
654 try writer.print(
655 " textLength=\"{d:.2}\" lengthAdjust=\"spacingAndGlyphs\"",
656 .{width},
657 );
658 }
659 try writer.writeByte('>');
660 try writeEscaped(writer, value);
661 try writer.writeAll("</text>");
662 }
663
664 fn writeAccessibleName(
665 writer: *std.Io.Writer,
666 node: model.Node,
667 ) std.Io.Writer.Error!void {
668 if (node.title.len > 0) return writeEscaped(writer, node.title);
669 if (node.badge.len > 0) {
670 try writeEscaped(writer, node.badge);
671 try writer.writeByte('.');
672 }
673 if (node.context.len > 0) {
674 try writeEscaped(writer, node.context);
675 try writer.writeByte('.');
676 }
677 try writeEscaped(writer, node.label);
678 }
679
680 fn writeEscaped(
681 writer: *std.Io.Writer,
682 value: []const u8,
683 ) std.Io.Writer.Error!void {
684 var escaped: [escaped_bytes_max]u8 = undefined;
685 const length = html.escapedLength(value) catch unreachable;
686 std.debug.assert(length <= escaped.len);
687 try writer.writeAll(html.writeEscaped(&escaped, value));
688 }